Lucas Sifoni

Playing with HTML5 Canvas from Elixir

elixirprogramming


I’m exploring little things in building a game, and stumbled on these thoughts :

A quick experiment lead me to write some code to get the ball rolling. My small game is a circular PONG game. Circular PONG is great because there are all sorts of little trigonometry tricks to handle the required math in a laid-back way, which makes it a great teaching material, because we can really build a small game without thinking too much about physics and geometry.

Game state

Here’s my game state when it starts. Three players (paddles) are represented by their angular start and end positions, and a ball is represented by the (normalized) radius it sits on, and the angle (in radians) giving its direction.

%Pong2pi.Game{
  difficulty: 0.3,
  players: [
    %Pong2pi.Player{
      pos: 0.3141592653589793,
      speed: 0,
      tilt: 0,
      start_pos: 0,
      end_pos: 0.6283185307179586
    },
    %Pong2pi.Player{
      pos: 2.408554367752175,
      speed: 0,
      tilt: 0,
      start_pos: 2.0943951023931957,
      end_pos: 2.7227136331111543
    },
    %Pong2pi.Player{
      pos: 4.502949470145371,
      speed: 0,
      tilt: 0,
      start_pos: 4.188790204786391,
      end_pos: 4.81710873550435
    }
  ],
  ball: %Pong2pi.Ball{radius: 0, angle: 0, speed: 0}
}

Here’s how that could look rendered : three players evenly spaced, taking 30% of the perimeter of the unit circle (hence the difficulty: 0.3 value), and a ball at the center.

I started by simply defining a render function, taking in a game struct, some viewport options, and defining a list of operations it should apply on the canvas. clear_screen clearly doesn’t need to get the game struct, whereas draw_players and draw_ball need it.

Then, those operations are transformed to commands, with the render_commands function. I then inject them into an HTML template, and open it in my browser.

  def render(%Game{} = game, options \\ [width: 800, height: 800]) do
    w = options[:width]
    h = options[:height]

    operations = [
      clear_screen(w, h),
      draw_players(game, w, h),
      draw_ball(game, w, h)
    ]

    commands = render_commands(operations, :html5)

    rendered = Template.inject(commands, options[:width], options[:height])

    File.write("out.html", rendered)
    System.cmd("open", ["out.html"])
  end

The template is quite simple too :

<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
    </head>
    <body>
        <canvas id="canvas"></canvas>
        <script>
            function runCommand(command, context, width, height) {
              // ???
            }

            (function() {
                const c = document.getElementById('canvas');
                const ctx = c.getContext("2d");
                const width = #{width};
                const height = #{height};
                c.width = width;
                c.height = height;
                const commands = #{Jason.encode!(commands)};
                commands.forEach(c => {
                    runCommand(c, ctx, width, height);
                });
            })();
        </script>
    </body>
    </html>

It contains a canvas, an IIFE, and our list of commands is injected into it, before being ran, one-by-one. By filling in the clear_screen and draw_players functions, I got an idea of how those commands would look like :

 defp clear_screen(w, h) do
    [
      {:fill_style, "black"},
      {:fill_rect, [0, 0, w, h]}
    ]
  end

  defp draw_players(%Game{} = game, w, h) do
    for player <- game.players do
      [
        :begin_path,
        {:line_width, 20},
        {:stroke_style, "red"},
        {:arc, [w / 2, h / 2, Enum.min([w, h]) / 2 * 0.7, player.start_pos, player.end_pos]},
        :stroke,
        :close_path
      ]
    end
  end

It’s a bit like writing HTML5 Canvas operations, but with a leaner syntax. The differences between properties (like ctx.strokeStyle) and functions (like ctx.stroke()) disappeared, and we’re left with descriptors of the operations that should take place.

To clean up the writing style, I did not standardize on a common {:kind, [:arguments, :list]} syntax. The render_command function takes care of normalizing that :

  def render_command(command, :html5) do
    case command do
      a when is_atom(a) -> %{name: a, args: []}
      {a, args} when is_atom(a) and is_list(args) -> %{name: a, args: args}
      {a, arg} -> %{name: a, args: [arg]}
    end
  end

On the HTML side, we get a bunch of objects describing the various calls :

const commands = [{"args":["black"],"name":"fill_style"},{"args":[0,0,800,800],"name":"fill_rect"},{"args":[],"name":"begin_path"},{"args":[20],"name":"line_width"},{"args":["red"],"name":"stroke_style"},{"args":[400.0,400.0,280.0,0,0.6283185307179586],"name":"arc"},{"args":[],"name":"stroke"},{"args":[],"name":"close_path"},{"args":[],"name":"begin_path"},{"args":[20],"name":"line_width"},{"args":["red"],"name":"stroke_style"},{"args":[400.0,400.0,280.0,2.0943951023931957,2.7227136331111543],"name":"arc"},{"args":[],"name":"stroke"},{"args":[],"name":"close_path"},{"args":[],"name":"begin_path"},{"args":[20],"name":"line_width"},{"args":["red"],"name":"stroke_style"},{"args":[400.0,400.0,280.0,4.188790204786391,4.81710873550435],"name":"arc"},{"args":[],"name":"stroke"},{"args":[],"name":"close_path"},{"args":[],"name":"begin_path"},{"args":["white"],"name":"fill_style"},{"args":[425.0,425.0,10,0,0],"name":"arc"},{"args":[],"name":"fill"},{"args":[],"name":"close_path"}];

As I kept the discipline of using the snake_cased versions of the camelCased properties of the JS side, we can roll a function like so :

function runCommand(command, context, width, height) {
    const parts = command.name.split("_");
    let pName = '';
    if (parts.length === 1) {
        pName = parts[0];
    } else {
        pName = parts[0] + (parts.slice(1).map(p => `${p.charAt(0).toUpperCase()}${p.slice(1)}`).join(''));
    }
    if (typeof context[pName] === "undefined") return;
    if (typeof context[pName] === "function") {
        context[pName](...command.args);
    } else {
       context[pName] = command.args[0];
    }
}

We first convert the command name to camelCase, close_path becoming closePath, and then check if this property exists on the context (CanvasRenderingContext2D) object. If it is a function, we call it with the args, and if it is a simple property (like strokeStyle), we can assign to it.

Generating an interpreter server-side

What’s cool about that is that the rendering logic and calculations are done on the server based on the game state, and become a simple loop of draw calls on the HTML side, without computation of values on the clients. But there’s still a bit of ifs and elses, checks and string manipulations at runtime, and that’s something we could easily avoid while keeping the ease of having a simple list of commands sent to the client.

As some of the Canvas API functions are variadic, we will use a fixed number of parameters for every of them, even if that means filling default parameters on the elixir side when it shouldn’t be needed.

Let’s modify the render function to render an interpreter as the JS code to draw on our canvas.

This map goes from an operation, to a tuple containing its operation ID, whether it is a call or a property, and in case it is a call, the number of arguments it should consume. We could find a way to generate the full map from the Canvas spec, but let’s just write the few method calls and properties we use in this example.

  @call_map %{
    begin_path: {0, :call, 0},
    fill: {1, :call, 0},
    stroke: {2, :call, 0},
    close_path: {3, :call, 0},
    fill_style: {4, :prop},
    line_width: {5, :prop},
    stroke_style: {6, :prop},
    fill_rect: {7, :call, 4},
    arc: {8, :call, 6}
  }

We then have a render_interpreter function rendering the JS function needed to run commands :

  def render_interpreter() do
    """
    const run = (context, commands) => {
       let len = commands.length;
       while (len > 0) {
        const item = commands.shift();
        len--;
        switch (item) {
          #{render_switch_branches() |> Enum.join("\n")}
        }
      }
    };
    """
  end

  defp render_switch_branches() do
    for {k, v} <- @call_map do
      uppercased_key = camel(k)
      case v do
        {_id, :call, _arglength} -> render_call_branch(v, uppercased_key)
        {_id, :prop} -> render_prop_branch(v, uppercased_key)
      end
    end
  end

  defp render_call_branch({id, :call, arglength}, uppercased_key) do
    """
      case #{id}:
        args = commands.splice(0, #{arglength});
        context.#{uppercased_key}(...args);
        len -= #{arglength};
        break;
    """
  end

  defp render_prop_branch({id, :prop}, uppercased_key) do
    """
      case #{id}:
        context.#{uppercased_key} = commands.shift();
        len -= 1;
        break;
    """
  end

We could have a special case for the 0-arity functions, but this gives the general idea. The script in the HTML template is modified :

    <script>
        (function() {
            const c = document.getElementById('canvas');
            const ctx = c.getContext("2d");
            const width = #{width};
            const height = #{height};
            c.width = width;
            c.height = height;
            const commands = #{Jason.encode!(commands)};
            #{interpreter}
            run(ctx, commands);
        })();
    </script>

And render_commands is modified too to just output a list of command IDS and arguments, by flat_mapping over render_command :

  defp get_id(call), do: @call_map |> Map.get(call) |> elem(0)
  def render_command(command, :html5) do
    case command do
      a when is_atom(a) -> [get_id(a)]
      {a, args} when is_atom(a) and is_list(args) -> [get_id(a) | args]
      {a, arg} -> [get_id(a) | [arg]]
    end
  end

On the rendered HTML, the interpreter is renedered as a switch, consuming the right number of arguments until the end of the list of commands :

    const commands = [4, "black", 7, 0, 0, 800, 800, 0, 5, 20, 6, "red", 8, 400.0, 400.0, 280.0, 0, 0.6283185307179586, false, 2, 3, 0, 5, 20, 6, "red", 8, 400.0, 400.0, 280.0, 2.0943951023931957, 2.7227136331111543, false, 2, 3, 0, 5, 20, 6, "red", 8, 400.0, 400.0, 280.0, 4.188790204786391, 4.81710873550435, false, 2, 3, 0, 4, "white", 8, 400.0, 400.0, 10, 0, 6.283185307179586, false, 1, 3];

    const run = (context, commands) => {
    let len = commands.length;
    while (len > 0) {
        const item = commands.shift();
        len--;
        switch (item) {
        case 8:
            args = commands.splice(0, 6);
            context.arc(...args);
            len -= 6;
            break;

        case 0:
            args = commands.splice(0, 0);
            context.beginPath(...args);
            len -= 0;
            break;

        case 3:
            args = commands.splice(0, 0);
            context.closePath(...args);
            len -= 0;
            break;

        case 1:
            args = commands.splice(0, 0);
            context.fill(...args);
            len -= 0;
            break;

        case 7:
            args = commands.splice(0, 4);
            context.fillRect(...args);
            len -= 4;
            break;

        case 4:
            context.fillStyle = commands.shift();
            len -= 1;
            break;

        case 5:
            context.lineWidth = commands.shift();
            len -= 1;
            break;

        case 2:
            args = commands.splice(0, 0);
            context.stroke(...args);
            len -= 0;
            break;

        case 6:
            context.strokeStyle = commands.shift();
            len -= 1;
            break;

        }
    }
    };

    run(ctx, commands);

What’s fun is that we don’t have to maintain this JS part, as it gets generated by the Elixir code.

What could be done too would be to abstract general drawing operations first, then render them to commands for the HTML5 backend, like so :

def draw_players(players, w, h) do
  r = Enum.min([w, h])
  for player <- players do
    {:draw_arc, thickness: 20, color: "red", angular_pos: player.pos, radius: r}
  end
end

def render_command({:draw_arc, options}, :html5) do
  [
    :begin_path,
    {:line_width, 10}, # fetch line width
    {:stroke_style, "red"}, # fetch color
    {:arc, []}, # fetch arc options
    :stroke,
    :close_path
  ]
end

Without going that far, we can also define little functions that abstract over repetitive operations, just like clear_screen or draw_players do.

Conclusion

So, to wrap it up, these crude experiments could maybe be some ways you could render highly-expressive Elixir calls to a canvas. I think it “clicks” well with things like LiveView, where the browser can be seen as only displaying a server-side truth. Of course, you wouldn’t write an HTML file and open it every frame, but the page could connect to a socket and receive draw commands for each frame. And you don’t get inputs from the page easily.

Not for prod, but made me think on how I’d like to write all those calls to ctx.fillRect :-) .


Previous post : A lightweight and easy to build 1.25" crayford focuser
Next post : From a fresh ubuntu 22.04 ARM VM to building nerves_system_rpi3 & circuits_quickstart