Opal: Ruby in Your Browser, the Basics

Tweet

I love Ruby, and it is my go-to language for building web applications. Unfortunately, when dealing with the browser, Javascript is a necessary evil. As you can see, I am not a huge fan.

So when someone comes along offering a way to use Ruby on the browser, sign me up!

In the first part of this article, I will introduce Opal and show how to get Opal set up. After that, we’ll dive in and implement the first half of our example application. (I will keep you in suspense for now, unless you scroll down, which will spoil the fun.)

Hello, Opal!

That someone who came along happens to be Adam Beynon, creator of Opal. Opal is a Ruby to Javascript source-to-source compiler. In other words, Opal translates the Ruby that you write into Javascript.

Getting Opal

Fire up a terminal:

% gem install opal opal-jquery
Successfully installed opal-0.6.0
Successfully installed opal-jquery-0.2.0
2 gems installed

Notice that we are also installing opal-jquery. This gem wraps jQuery and provides a Ruby syntax to interact with the DOM. More on that later.

Let’s give Opal in spin in irb:

% irb                                                                               
> require 'opal'
=> true
> Opal.compile("3.times { puts 'Ohai, Opal!' }")
=> "/* Generated by Opal 0.6.0 */\n(function($opal) {\n  var $a, $b, TMP_1, self = $opal.top, $scope = $opal, nil = $opal.nil, $breaker = $opal.breaker, $slice = $opal.slice;\n\n  $opal.add_stubs(['$times', '$puts']);\n  return ($a = ($b = (3)).$times, $a._p = (TMP_1 = function(){var self = TMP_1._s || this;\n\n  return self.$puts(\"Ohai, Opal!\")}, TMP_1._s = self, TMP_1), $a).call($b)\n})(Opal);\n"

Conway’s Game of Life in Opal

It’s time to get our hands dirty and feet wet with Opal. I have always wanted a reason to build Conway’s Game of Life, so that’s our goal.

In case you are not familiar with Conway’s Game of Life (or too lazy to read the Wikipedia entry):

It starts with an empty grid of square cells. Every cell is either alive or dead. Each cell interacts with its eight neighbors.

Here, there 5 cells that are alive. The rest are dead. The cell marked with a blue dot is shown together with its 8 neighbors marked with red dots.

At each tick, a cell can undergo a transition based on four rules:

Rule 1

Any live cell with fewer than two live neighbors dies, as if caused by under-population.

Rule 2

Any live cell with two or three live neighbors lives on to the next generation.

Rule 3

Any live cell with more than three live neighbors dies, as if by overcrowding.

Rule 4

Any dead cell with exactly three live neighbors becomes a live cell, as if by reproduction.

Amazingly, with just these 4 simple rules we can observe very interesting patterns. Here’s an example, called the lightweight spaceship, taken from ConwayLife.com:

Here is the spaceship in action:

Setting up

Go ahead an create an empty directory, call it conway_gol, and create the following directory structure:

├── Gemfile
├── Rakefile
├── app
│   ├── conway.rb
├── index.html
└── styles.css

1. Gemfile

Populate your Gemfile to look like this:

source 'https://rubygems.org'

gem 'opal'
gem 'opal-jquery'
gem 'guard'
gem 'guard-rake'

After that, install the gems:

% bundle install

2. Setting Up Guard

Notice that we’re including the guard gem.

Guard is extremely handy for development with Opal. Since Opal is a compiler, it needs to compile the Ruby code that you have written into Javascript. Therefore, each time you make changes, you recompile the source.Guard makes this process slightly easier.

Guard watches certain files or directories based on rules set in a Guardfile, which we shall create shortly. It also comes with a bunch of handy plugins. For example, guard-rake runs a Rake task when files change.

Next, in the conway_gol directory, create a Guardfile using the following command:

% bundle exec guard init
00:30:21 - INFO - rake guard added to Guardfile, feel free to edit it

Include this rule in your Guardfile.

guard 'rake', :task => 'build' do
  watch %r{^app/.+\.rb$}
end

This watches for any changes in any Ruby file in the app directory. Such a change will trigger the rake build task, which we will write next.

3. Setting Up the Rakefile

In Rakefile:

require 'opal'
require 'opal-jquery'

desc "Build our app to conway.js"
task :build do
  env = Opal::Environment.new
  env.append_path "app"

  File.open("conway.js", "w+") do |out|
    out << env["conway"].to_s
  end
end

Here is what the build Rake task does:

  1. Sets up the directory to which the Ruby files are stored
  2. Creates conway.js, the result of the Ruby -> Javascript compilation.

4. Static Files

In index.html:

<!DOCTYPE html>
<html>
  <head>
    <script src="http://code.jquery.com/jquery-1.11.0.min.js"></script>
    <script src="http://code.jquery.com/jquery-migrate-1.2.1.min.js"></script>
    <link rel="stylesheet" href="styles.css">
  </head>
  <body>
    <canvas id="conwayCanvas"></canvas>
    <script src="conway.js"></script>
  </body>
</html>

We need jQuery. We are also using the HTML5 canvas element, so the usual “this will not work on older versions of Internet Explorer” disclaimer applies.

Lastly, we put the Opal-generated conway.js below the canvas element. This is so the canvas element is available to conway.js.

In styles.css:

* {
  margin: 0;
  padding: 0;
}

This style gets rid of any gaps at the border when our grid is drawn.

5. Working with Opal

Before we use Guard to automate the process, here is an example on how you would interact with Opal:

Go to app/conway.rb, and type this in:

require 'opal'

x = (0..3).map do |n|
      n * n * n
    end.reduce(:+)
puts x

In your terminal, under the conway_gol directory, run the Rake task:

% rake build

Open index.html. The result appears on the developer console of your browser. Here is what it looks like in Chrome:

Just for kicks, go see what the generated conway.js looks like. While you admire the generated Javascript, take a moment to reflect on the brilliance of the Opal team.

Since we have Guard all set up, in another terminal window, run this command:

% bundle exec guard
01:11:39 - INFO - Guard is using TerminalTitle to send notifications.
01:11:39 - INFO - Starting guard-rake build
01:11:39 - INFO - running build

01:11:41 - INFO - Guard is now watching at '/Users/Ben/conway_gol'
[1] guard(main)>

Here’s a tip: Anytime you want to re-run rake build, simply press ‘Enter’ in the Guard terminal window:

[1] guard(main)> (Hit Enter)
01:13:42 - INFO - Run all
01:13:42 - INFO - running build

Try making a change in conway.rb:

require 'opal'

x = (0..3).map do |n|
      n * n * n
    end.reduce(:*) # <- change to this
puts x

Observe that, when you save conway.rb (or any Ruby file for that matter), the terminal window running Guard outputs this message:

09:25:11 - INFO - running build

Refreshing the browser will display the updated value.

Now that we have made sure everything works, go ahead and delete everything in conway.rb – The fun begins now!

Let the Games Begin!

Our application will consist of 2 major components. The first piece is the game logic. The second piece is drawing of the canvas and handling canvas events.

Let’s tackle the second piece first.

1. Drawing a Blank Grid

This is what we’re shooting for:

The grid lines cover the entire browser viewport. This means that we need to access the height and width of the this viewport. More importantly, we need to access the canvas element on the DOM before we can start drawing anything.

The Grid Class

Open conway.rb in app, and fill it in with this:

require 'opal'
require 'opal-jquery'

class Grid
  attr_reader :height, :width, :canvas, :context, :max_x, :max_y

  CELL_HEIGHT = 15;
  CELL_WIDTH  = 15;

  def initialize
    @height  = `$(window).height()`                  
    @width   = `$(window).width()`                   
    @canvas  = `document.getElementById(#{canvas_id})` 
    @context = `#{canvas}.getContext('2d')`
    @max_x   = (height / CELL_HEIGHT).floor           
    @max_y   = (width / CELL_WIDTH).floor             
  end

  def draw_canvas
    `#{canvas}.width  = #{width}`
    `#{canvas}.height = #{height}`

    x = 0.5
    until x >= width do
      `#{context}.moveTo(#{x}, 0)`
      `#{context}.lineTo(#{x}, #{height})`
      x += CELL_WIDTH
    end

    y = 0.5
    until y >= height do
      `#{context}.moveTo(0, #{y})`
      `#{context}.lineTo(#{width}, #{y})`
      y += CELL_HEIGHT
    end

    `#{context}.strokeStyle = "#eee"`
    `#{context}.stroke()`
  end

  def canvas_id
    'conwayCanvas'
  end

end

grid = Grid.new
grid.draw_canvas

We need to explicitly require opal and opal-jquery.

The Grid class looks mostly like Ruby. At first glance, we have all the usual Ruby syntax. Let’s look at each part of this class in slightly more detail, starting with initialize.

initialize
class Grid
  attr_reader :height, :width, :canvas, :context, :max_x, :max_y

  CELL_HEIGHT = 15;
  CELL_WIDTH  = 15;

  def initialize
    @height  = `$(window).height()`                  
    @width   = `$(window).width()`                   
    @canvas  = `document.getElementById(#{canvas_id})` 
    @context = `#{canvas}.getContext('2d')`
    @max_x   = (height / CELL_HEIGHT).floor           
    @max_y   = (width / CELL_WIDTH).floor             
  end

  def canvas_id
    'conwayCanvas'
  end

  ### snip snip ###
end

In Opal, we can evaluate Javascript directly in back-ticks. For instance, to get the height of the browser viewport:

@height  = `$(window).height()`

Opal stores the value of @height as a Numeric Ruby class. We are also using $, which is calling a jQuery instance.

Working with Canvas

Don’t worry if you have never worked with the canvas element before, since that’s not the main point of this article, anyway. Everything I know about canvas came from Dive Into HTML5.

@canvas  = `document.getElementById(#{canvas_id})`

To work with the canvas, you need a reference to it in the DOM. Look at how we can use string interpolation to fill in the canvas id via a canvas_id method call.

@context = `#{canvas}.getContext('2d')`

More importantly, every canvas has a drawing context. All the drawing is done via this context. Notice how we use string interpolation once again to pass in canvas, retrieve the context, and store it in @context.

@max_x   = (height / CELL_HEIGHT).floor           
@max_y   = (width / CELL_WIDTH).floor

@max_x and @max_y store the limits of the grid in terms of coordinates, which explains why we need to divide by CELL_HEIGHT and CELL_WIDTH.

draw_canvas

This is how we draw the grid lines on the canvas. canvas is only called to set the width and height. All the drawing is handled with function calls to context.

draw_canvas is a nice example of how Opal lets you use Ruby and Javascript code together in perfect harmony.

def draw_canvas
  `#{canvas}.width  = #{width}`
  `#{canvas}.height = #{height}`

  x = 0.5
  until x >= width do
    `#{context}.moveTo(#{x}, 0)`
    `#{context}.lineTo(#{x}, #{height})`
    x += CELL_WIDTH
  end

  y = 0.5
  until y >= height do
    `#{context}.moveTo(0, #{y})`
    `#{context}.lineTo(#{width}, #{y})`
    y += CELL_HEIGHT
  end

  `#{context}.strokeStyle = "#eee"`
  `#{context}.stroke()`
end
Let’s See the Canvas

Finally, getting the grid to draw is just a simple method call away:

grid = Grid.new
grid.draw_canvas

If you are running Guard press ‘Enter’, or you could run rake build. Either way, when you open index.html, you will see a glorious grid.

2. Adding Some Interactivity

Being able to draw grid lines on a canvas – in Ruby, no less! – is all well and good, but entirely useless if we cannot do anything to it.

Let’s spice things up a little.

One of the things we can to do is fill in a cell. In order to do that, we need to know where we clicked, and then compute the cell’s position with respect to our grid. That is, we need to figure out the clicked coordinates based on the grid we drew.

Even before knowing where we clicked, we need to know when we clicked. In this section, we also look at how opal-jquery lets us use Ruby to interact with jQuery’s event listeners.

Fill and Unfilling a Cell

The following methods draw a black square and clears a square of the same dimensions:

def fill_cell(x, y)
  x *= CELL_WIDTH;
  y *= CELL_HEIGHT;
  `#{context}.fillStyle = "#000"`
  `#{context}.fillRect(#{x.floor+1}, #{y.floor+1}, #{CELL_WIDTH-1}, #{CELL_HEIGHT-1})`
end

def unfill_cell(x, y)
  x *= CELL_WIDTH;
  y *= CELL_HEIGHT;
  `#{context}.clearRect(#{x.floor+1}, #{y.floor+1}, #{CELL_WIDTH-1}, #{CELL_HEIGHT-1})`
end

Getting the Position of the Cursor

Here’s an example on translating Javascript into Ruby. I was too lazy and impatient to figure out how to implement this particular function. As it turns out, Dive Into HTML 5 already has an example in Javascript:

function getCursorPosition(event) {
  var x;
  var y;
  if (event.pageX != undefined && event.pageY != undefined) {
    x = event.pageX;
    y = event.pageY;
  }
  else {
    x = event.clientX + document.body.scrollLeft +
          document.documentElement.scrollLeft;
    y = event.clientY + document.body.scrollTop +
          document.documentElement.scrollTop;
  }
}

Now, let’s see what the Opal-flavored Ruby looks like:

def get_cursor_position(event)
  if (event.page_x && event.page_y)
    x = event.page_x;
    y = event.page_y;
  else
    doc = Opal.Document[0]
    x = event[:clientX] + doc.scrollLeft + 
          doc.documentElement.scrollLeft;
    y = event[:clientY] + doc.body.scrollTop + 
          doc.documentElement.scrollTop;
  end
end

As you can see, there’s almost a one to one conversion.

You might be wondering why are there 2 branches just to find the cursor position. The short answer is different browsers have different ways of implementing this functionality.

Discovering Methods

How did I know, for example, that the page_x method exists for event or that clientX should be accessed using the hash notation?

def get_cursor_position(event)
  `console.log(#{event})` # <- I cheated.
  # code omitted
end

I did not use puts event or even p event. I picked console.log instead.

Here’s why:

Using console.log gives us much more detail, since event is first and foremost, a Javascript object. Using puts, p or even inspect doesn’t do much.

In the if branch, we access event using the dot notation, while in the else branch, we treat event like a hash.

In the green box, event.page_x is a method call because there indeed is a page_x function defined as $page_x: function { ... }.

In the purple box, clientX is a value. Therefore, it is accessed using the hash notation.

Here is some additional code to compute the coordinates respect to the grid.

def get_cursor_position(event)

  ## Previous code omitted ...

  x -= `#{canvas}.offsetLeft`
  y -= `#{canvas}.offsetTop`

  x = (x / CELL_WIDTH).floor
  y = (y / CELL_HEIGHT).floor

  Coordinates.new(x: x, y: y)
end

Coordinates and OpenStruct

I’ve snuck in a Coordinates class. Interestingly, Opal has OpenStruct, too.

You can define Coordinates like so:

require 'opal'
require 'opal-jquery'
require 'ostruct'     # <- remember to do this!

class Grid
  # ...
end

class Coordinates < OpenStruct; end

Just like the Ruby version, we need to require ostruct in order to use it.

Event Listening

Finally, we have all the building blocks to listen for events. Both listeners will listen for events on canvas.

The first event listener triggers on a single click mouse event. Once that happens, the cursor position is computed and the appropriate cell is filled.

The second event listener triggers on a double click mouse event. Again, the cursor position is computed and the appropriate cell is unfilled.

def add_mouse_event_listener
  Element.find("##{canvas_id}").on :click do |event|
    coords = get_cursor_position(event)
    x, y   = coords.x, coords.y
    fill_cell(x, y)
  end

  Element.find("##{canvas_id}").on :dblclick do |event|
    coords = get_cursor_position(event)
    x, y   = coords.x, coords.y
    unfill_cell(x, y)
  end
end

After drawing the canvas, register the mouse listener:

class Grid
  # ...
end

grid = Grid.new
grid.draw_canvas
grid.add_mouse_event_listener # <- Add this!

Once our changes are built, go ahead and open index.html. Try clicking on any grid to mark a cell and double clicking to unmark a cell.

Here’s my masterpiece:

The Full Source

For reference, here’s the full source code:

require 'opal'
require 'opal-jquery'
require 'ostruct'     

class Grid
  attr_reader :height, :width, :canvas, :context, :max_x, :max_y

  CELL_HEIGHT = 15;
  CELL_WIDTH  = 15;

  def initialize
    @height  = `$(window).height()`                   # Numeric!
    @width   = `$(window).width()`                    # A Numeric too!
    @canvas  = `document.getElementById(#{canvas_id})` 
    @context = `#{canvas}.getContext('2d')`
    @max_x   = (height / CELL_HEIGHT).floor           # Defines the max limits
    @max_y   = (width / CELL_WIDTH).floor             # of the grid
  end

  def draw_canvas
    `#{canvas}.width  = #{width}`
    `#{canvas}.height = #{height}`

    x = 0.5
    until x >= width do
      `#{context}.moveTo(#{x}, 0)`
      `#{context}.lineTo(#{x}, #{height})`
      x += CELL_WIDTH
    end

    y = 0.5
    until y >= height do
      `#{context}.moveTo(0, #{y})`
      `#{context}.lineTo(#{width}, #{y})`
      y += CELL_HEIGHT
    end

    `#{context}.strokeStyle = "#eee"`
    `#{context}.stroke()`
  end

  def get_cursor_position(event)
    puts event
    p event
    `console.log(#{event})`
    if (event.page_x && event.page_y)
      x = event.page_x;
      y = event.page_y;
    else
      doc = Opal.Document[0]
      x = e[:clientX] + doc.scrollLeft + doc.documentElement.scrollLeft;
      y = e[:clientY] + doc.body.scrollTop + doc.documentElement.scrollTop;
    end

    x -= `#{canvas}.offsetLeft`
    y -= `#{canvas}.offsetTop`

    x = (x / CELL_WIDTH).floor
    y = (y / CELL_HEIGHT).floor

    Coordinates.new(x: x, y: y)
  end

  def fill_cell(x, y)
    x *= CELL_WIDTH;
    y *= CELL_HEIGHT;
    `#{context}.fillStyle = "#000"`
    `#{context}.fillRect(#{x.floor+1}, #{y.floor+1}, #{CELL_WIDTH-1}, #{CELL_HEIGHT-1})`
  end

  def unfill_cell(x, y)
    x *= CELL_WIDTH;
    y *= CELL_HEIGHT;
    `#{context}.clearRect(#{x.floor+1}, #{y.floor+1}, #{CELL_WIDTH-1}, #{CELL_HEIGHT-1})`
  end

  def add_mouse_event_listener
    Element.find("##{canvas_id}").on :click do |event|
      coords = get_cursor_position(event)
      x, y   = coords.x, coords.y
      fill_cell(x, y)
    end

    Element.find("##{canvas_id}").on :dblclick do |event|
      coords = get_cursor_position(event)
      x, y   = coords.x, coords.y
      unfill_cell(x, y)
    end
  end

  def canvas_id
    'conwayCanvas'
  end

end

class Coordinates < OpenStruct; end

grid = Grid.new
grid.draw_canvas
grid.add_mouse_event_listener

Up Next …

In a future post, we’ll complete our Conway’s Game of Life application by implementing the game logic and hooking it to our grid. In doing so, we’ll also get to see a few more examples of how Opal gracefully blends Ruby and Javascript,

Thanks for reading!

Free JavaScript: Novice to Ninja Sample

Get a free 32-page chapter of JavaScript: Novice to Ninja and receive updates on exclusive offers from SitePoint.

  • benjamintanweihao

    Thank you Julian. Should be fixed real soon.

  • Maurizio De Santis

    Instead of OpenStuct you can use:

    Coordinates = Struct.new(:x, :y)
    coords = Coordinates.new 1, 2

    Since Coordinates is always x and y

    • benjamintanweihao

      Very true! In fact, I tried Struct at first too. But then I was wondering if OpenStruct could work too :)

  • http://jared.mariposta.com/ Jared White

    Awesome intro to Opal. Great to see more coverage of this tool on the web. :) I’ve already written some Opal code on an existing project but I’m hoping at some point to write a complete web application using Opal on the front-end.

  • BernhardW.

    Benjamin, thanks for this article. Using Opal in a first project, I find debugging pretty cumbersome. Did you manage to make sourcemaps work? I could not get anything in the sourcemaps. In particular the ‘require’ files do not appear there.

  • http://esteban.bracketfactory.com/ Damian Esteban

    This is an older post (but still a great tutorial) but seeing as how someone commented on this article only 10 days ago, I’ve decided to comment. The whole point of Opal is to NOT use CoffeeScript. There are literally hundreds of CoffeeScript tutorials out there, and many, many good books. As far as Opal goes this is one of the only resources out there (and it is excellent, may I add).