Back to blog
elixir popcorn wasm atomvm game browser

Build a Snake Game in Elixir That Runs in Your Browser

Alembic Labs ·

Build a Snake Game in Elixir That Runs in Your Browser

What if you could write a game in Elixir and run it directly in your browser? No Phoenix, no LiveView, no server at all. Just pure Elixir, compiled to WebAssembly, running client-side.

That’s exactly what Popcorn makes possible. Released by Software Mansion (the team behind Membrane), Popcorn wraps AtomVM in WebAssembly. AtomVM is a tiny Erlang VM designed for microcontrollers. Now it runs in your browser too.

In this tutorial, we’ll build a classic Snake game from scratch. You’ll learn:

  • How Popcorn bridges Elixir and JavaScript
  • Managing game state with a GenServer (yes, in the browser!)
  • Rendering to Canvas via JS interop
  • Handling keyboard events from Elixir
  • Avoiding common pitfalls with AtomVM limitations

Play the game live here to see what we’re building.

Let’s go.

Prerequisites

Popcorn currently requires specific versions:

  • Elixir 1.17.3
  • OTP 26.0.2

This is because Popcorn patches some stdlib modules to work with AtomVM. The team is working on broader compatibility.

I recommend using mise or asdf for version management:

# .tool-versions
elixir 1.17.3-otp-26
erlang 26.0.2

Project Setup

Create a new Elixir project:

mix new snake_game
cd snake_game

Add Popcorn to your dependencies in mix.exs:

defp deps do
  [
    {:popcorn, github: "software-mansion/popcorn"}
  ]
end

Fetch dependencies:

mix deps.get

Now configure Popcorn. Create config/config.exs:

import Config

config :popcorn,
  start_module: SnakeGame.Start,
  out_dir: "static/wasm"

The start_module is your entry point. Popcorn will start its supervision tree when the page loads.

Project Structure

Here’s what we’re building:

snake_game/
├── config/
│   └── config.exs
├── lib/
│   └── snake_game/
│       ├── application.ex  # OTP Application
│       ├── start.ex        # Entry point GenServer
│       ├── game.ex         # Game state GenServer
│       └── ui.ex           # JavaScript interop
├── static/
│   ├── index.html          # Our HTML page
│   └── wasm/               # Generated by Popcorn
├── mix.exs
└── mix.lock

The Application

First, let’s set up a proper OTP Application. Create lib/snake_game/application.ex:

defmodule SnakeGame.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      SnakeGame.Start
    ]

    opts = [strategy: :one_for_one, name: SnakeGame.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

Update your mix.exs to include the application:

def application do
  [
    mod: {SnakeGame.Application, []},
    extra_applications: [:logger]
  ]
end

The Game State

Let’s start with the core: a GenServer that manages the snake, food, score, and game logic.

Create lib/snake_game/game.ex:

defmodule SnakeGame.Game do
  @moduledoc """
  GenServer managing the snake game state.
  """
  use GenServer

  @grid_size 20

  defstruct [
    :snake,
    :direction,
    :next_direction,
    :food,
    :score,
    :game_over
  ]

  # Client API

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  end

  def change_direction(direction) do
    GenServer.cast(__MODULE__, {:change_direction, direction})
  end

  def restart do
    GenServer.cast(__MODULE__, :restart)
  end

  def get_state do
    GenServer.call(__MODULE__, :get_state)
  end

  # Server callbacks

  @impl true
  def init(_) do
    state = new_game()
    # Note: Timer is managed by JavaScript setInterval
    {:ok, state}
  end

  @impl true
  def handle_call(:get_state, _from, state) do
    {:reply, state, state}
  end

  @impl true
  def handle_cast({:change_direction, new_dir}, state) do
    # Prevent 180° turns (can't go directly backwards)
    valid_change? =
      case {state.direction, new_dir} do
        {:up, :down} -> false
        {:down, :up} -> false
        {:left, :right} -> false
        {:right, :left} -> false
        _ -> true
      end

    if valid_change? do
      {:noreply, %{state | next_direction: new_dir}}
    else
      {:noreply, state}
    end
  end

  @impl true
  def handle_cast(:restart, _state) do
    state = new_game()
    {:noreply, state}
  end

  @impl true
  def handle_info(:tick, %{game_over: true} = state) do
    {:noreply, state}
  end

  @impl true
  def handle_info(:tick, state) do
    state = %{state | direction: state.next_direction}
    state = move_snake(state)

    # Render the new state
    SnakeGame.UI.render(state)

    {:noreply, state}
  end

  # Game logic

  defp new_game do
    snake = [{10, 10}, {9, 10}, {8, 10}]
    food = random_food(snake)

    %__MODULE__{
      snake: snake,
      direction: :right,
      next_direction: :right,
      food: food,
      score: 0,
      game_over: false
    }
  end

  defp move_snake(state) do
    [{head_x, head_y} | _] = state.snake

    new_head =
      case state.direction do
        :up -> {head_x, head_y - 1}
        :down -> {head_x, head_y + 1}
        :left -> {head_x - 1, head_y}
        :right -> {head_x + 1, head_y}
      end

    cond do
      out_of_bounds?(new_head) ->
        %{state | game_over: true}

      new_head in state.snake ->
        %{state | game_over: true}

      new_head == state.food ->
        new_snake = [new_head | state.snake]

        %{
          state
          | snake: new_snake,
            food: random_food(new_snake),
            score: state.score + 10
        }

      true ->
        new_snake = [new_head | Enum.drop(state.snake, -1)]
        %{state | snake: new_snake}
    end
  end

  defp out_of_bounds?({x, y}) do
    x < 0 or x >= @grid_size or y < 0 or y >= @grid_size
  end

  defp random_food(snake) do
    food = {:rand.uniform(@grid_size) - 1, :rand.uniform(@grid_size) - 1}

    if food in snake do
      random_food(snake)
    else
      food
    end
  end
end

Important: We don’t use Process.send_after/3 for the game timer. AtomVM doesn’t support the timer_manager module that Process.send_after/3 requires. Instead, we’ll use JavaScript’s setInterval to send tick messages. More on this in the UI section.

JavaScript Interop

Now the fun part: bridging Elixir and JavaScript. Popcorn provides Popcorn.Wasm.run_js/2 to execute JavaScript from Elixir.

The function signature is:

Popcorn.Wasm.run_js(js_function_string, args_map)

The JavaScript function receives a destructured object with:

  • args : The map you passed from Elixir
  • wasm : The Popcorn WASM module for calling back to Elixir

Create lib/snake_game/ui.ex:

defmodule SnakeGame.UI do
  @moduledoc """
  JavaScript interop for rendering the game via Canvas.
  """

  @cell_size 20
  @canvas_size @cell_size * 20

  def setup do
    # Create the canvas and UI elements
    Popcorn.Wasm.run_js(
      """
      ({ args }) => {
        const size = args.canvas_size;

        document.body.innerHTML = `
          <div style="display: flex; flex-direction: column; align-items: center;
                      justify-content: center; min-height: 100vh;
                      background: #1e1e2e; font-family: system-ui;">
            <h1 style="color: #cdd6f4; margin-bottom: 20px;">
              <span style="color: #cba6f7;">Snake</span> in Elixir
            </h1>
            <canvas id="canvas" width="${size}" height="${size}"
                    style="border: 2px solid #6c7086; border-radius: 8px;"></canvas>
            <div style="margin-top: 20px; color: #cdd6f4;">
              <span>Score: </span>
              <span id="score" style="color: #a6e3a1; font-weight: bold;">0</span>
            </div>
            <p style="color: #6c7086; margin-top: 10px; font-size: 14px;">
              Use arrow keys to move. Press R to restart.
            </p>
            <p style="color: #45475a; margin-top: 20px; font-size: 12px;">
              Built with <span style="color: #cba6f7;">Elixir</span> +
              <span style="color: #f9e2af;">Popcorn</span> (running in WebAssembly)
            </p>
          </div>
        `;
      }
      """,
      %{canvas_size: @canvas_size}
    )

    # Register keyboard event listener and game loop
    register_keyboard()
  end

  def render(state) do
    # Convert tuples to lists for JSON serialization
    snake_cells = Enum.map(state.snake, fn {x, y} -> [x, y] end)
    {food_x, food_y} = state.food

    Popcorn.Wasm.run_js(
      """
      ({ args }) => {
        const canvas = document.getElementById('canvas');
        const ctx = canvas.getContext('2d');
        const cellSize = args.cell_size;

        // Clear and draw grid
        ctx.fillStyle = '#1e1e2e';
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        ctx.strokeStyle = '#313244';
        ctx.lineWidth = 0.5;
        for (let i = 0; i <= canvas.width; i += cellSize) {
          ctx.beginPath();
          ctx.moveTo(i, 0);
          ctx.lineTo(i, canvas.height);
          ctx.stroke();
          ctx.beginPath();
          ctx.moveTo(0, i);
          ctx.lineTo(canvas.width, i);
          ctx.stroke();
        }

        // Draw food
        ctx.fillStyle = '#f38ba8';
        ctx.beginPath();
        ctx.arc(
          args.food_x * cellSize + cellSize / 2,
          args.food_y * cellSize + cellSize / 2,
          cellSize / 2 - 2,
          0,
          Math.PI * 2
        );
        ctx.fill();

        // Draw snake
        args.snake.forEach(([x, y], index) => {
          ctx.fillStyle = index === 0 ? '#a6e3a1' : '#94e2d5';
          ctx.beginPath();
          ctx.roundRect(
            x * cellSize + 1,
            y * cellSize + 1,
            cellSize - 2,
            cellSize - 2,
            4
          );
          ctx.fill();
        });

        // Update score
        document.getElementById('score').textContent = args.score;

        // Game over overlay
        if (args.game_over) {
          ctx.fillStyle = 'rgba(30, 30, 46, 0.85)';
          ctx.fillRect(0, 0, canvas.width, canvas.height);

          ctx.fillStyle = '#f38ba8';
          ctx.font = 'bold 32px system-ui';
          ctx.textAlign = 'center';
          ctx.fillText('Game Over', canvas.width / 2, canvas.height / 2 - 10);

          ctx.fillStyle = '#cdd6f4';
          ctx.font = '16px system-ui';
          ctx.fillText('Press R to restart', canvas.width / 2, canvas.height / 2 + 20);
        }
      }
      """,
      %{
        snake: snake_cells,
        food_x: food_x,
        food_y: food_y,
        score: state.score,
        game_over: state.game_over,
        cell_size: @cell_size
      }
    )
  end

  defp register_keyboard do
    Popcorn.Wasm.run_js("""
    ({ wasm, args }) => {
      // Keyboard events
      document.addEventListener('keydown', (e) => {
        const key = e.key;
        // Prevent arrow keys from scrolling the page
        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) {
          e.preventDefault();
        }
        wasm.cast('main', key);
      });

      // Game loop: send tick every 150ms
      setInterval(() => {
        wasm.cast('main', 'tick');
      }, 150);
    }
    """)
  end
end

Key Points About run_js

  1. Arrow function syntax: Use ({ args }) => { ... } not function(args) { ... }
  2. Destructuring: wasm and args are destructured from the parameter object
  3. Args are a map: Pass %{key: value} directly, not bindings: %{...}
  4. Access via args.key: Not args.bindings.key
  5. Call Elixir via wasm.cast: Use wasm.cast('process_name', data) to send messages

Rendering in a Single Call

Notice that render/1 does everything in a single run_js call. This is important for two reasons:

  1. Reduces flickering: Multiple sequential run_js calls (clear, draw food, draw snake) cause visible flickering because each call is a separate JS execution
  2. Better performance: Fewer round-trips between WASM and JS

JSON Serialization Gotcha

Tuples can’t be serialized to JSON. Popcorn uses Jason internally, and Jason doesn’t support tuples. You’ll get a cryptic Elixir.Jason.Encoder.Tuple.beam 404 error if you try.

Convert tuples to lists before passing to JavaScript:

# Bad: will error
Popcorn.Wasm.run_js("...", %{food: {5, 10}})

# Good
Popcorn.Wasm.run_js("...", %{food_x: 5, food_y: 10})

# Or convert list of tuples
snake_cells = Enum.map(state.snake, fn {x, y} -> [x, y] end)

The Entry Point

Now we need to wire everything together. Create lib/snake_game/start.ex:

defmodule SnakeGame.Start do
  @moduledoc """
  Entry point for the Snake game.
  Receives keyboard events from JavaScript and forwards them to the Game GenServer.
  """
  use GenServer
  import Popcorn.Wasm, only: [is_wasm_message: 1]
  alias Popcorn.Wasm

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: :main)
  end

  @impl true
  def init(_) do
    Wasm.register(:main)
    IO.puts("Snake game starting...")

    # Setup UI and start game after registration
    SnakeGame.UI.setup()
    SnakeGame.Game.start_link([])

    # Initial render
    state = SnakeGame.Game.get_state()
    SnakeGame.UI.render(state)

    IO.puts("Snake game ready! Use arrow keys to play.")
    {:ok, %{}}
  end

  @impl true
  def handle_info(raw_msg, state) when is_wasm_message(raw_msg) do
    Wasm.handle_message!(raw_msg, fn
      {:wasm_cast, "ArrowUp"} ->
        SnakeGame.Game.change_direction(:up)
        state

      {:wasm_cast, "ArrowDown"} ->
        SnakeGame.Game.change_direction(:down)
        state

      {:wasm_cast, "ArrowLeft"} ->
        SnakeGame.Game.change_direction(:left)
        state

      {:wasm_cast, "ArrowRight"} ->
        SnakeGame.Game.change_direction(:right)
        state

      {:wasm_cast, "r"} ->
        SnakeGame.Game.restart()
        state

      {:wasm_cast, "R"} ->
        SnakeGame.Game.restart()
        state

      {:wasm_cast, "tick"} ->
        send(SnakeGame.Game, :tick)
        state

      _ ->
        state
    end)

    {:noreply, state}
  end

  @impl true
  def handle_info(_msg, state) do
    {:noreply, state}
  end
end

Important: Message Handling

Messages from JavaScript via wasm.cast arrive as raw WASM messages. You must use Popcorn.Wasm.handle_message!/2 to parse them:

  1. Import the guard: import Popcorn.Wasm, only: [is_wasm_message: 1]
  2. Pattern match with when is_wasm_message(raw_msg)
  3. Use Wasm.handle_message!/2 with a callback function
  4. Pattern match on {:wasm_cast, data} tuples

If you try to pattern match directly on the string (e.g., def handle_info("ArrowUp", state)), it won’t work. The raw message is a tagged tuple, not a plain string.

The HTML Shell

Create static/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Snake in Elixir</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { background: #1e1e2e; }
    .loading {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      color: #cdd6f4;
      font-family: system-ui;
    }
    .spinner {
      width: 40px;
      height: 40px;
      border: 3px solid #313244;
      border-top-color: #cba6f7;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
  </style>
</head>
<body>
  <div class="loading" id="loading">
    <h1><span style="color: #cba6f7;">Snake</span> in Elixir</h1>
    <p style="margin: 1rem 0; color: #6c7086;">Loading WebAssembly runtime...</p>
    <div class="spinner"></div>
  </div>

  <script type="module">
    const loading = document.getElementById('loading');

    try {
      const { Popcorn } = await import('./wasm/popcorn.js');

      const popcorn = await Popcorn.init({
        bundlePath: 'wasm/bundle.avm',
        wasmDir: '/wasm/',
        onStdout: (msg) => console.log('[Elixir]', msg),
        onStderr: (msg) => console.error('[Elixir Error]', msg)
      });

      loading.style.display = 'none';
    } catch (e) {
      console.error('Failed to load Popcorn:', e);
      loading.innerHTML = `
        <h1 style="color: #f38ba8;">Error Loading Game</h1>
        <p style="color: #6c7086; margin-top: 1rem;">${e.message}</p>
      `;
    }
  </script>
</body>
</html>

Build and Run

Compile the project to WebAssembly:

mix popcorn.cook

This generates the WASM files in static/wasm/.

Important: You can’t just use any static file server. SharedArrayBuffer requires specific COOP/COEP headers. Python’s http.server or npx http-server won’t work out of the box.

Create server.exs at the project root:

# server.exs - Run with: elixir server.exs
Mix.install([{:plug, "~> 1.14"}, {:plug_cowboy, "~> 2.6"}])

defmodule StaticServer do
  use Plug.Router

  plug :add_coop_coep_headers
  plug Plug.Static, at: "/", from: "static"
  plug :match
  plug :dispatch

  get "/" do
    send_file(conn, 200, "static/index.html")
  end

  match _ do
    send_resp(conn, 404, "Not found")
  end

  defp add_coop_coep_headers(conn, _opts) do
    conn
    |> put_resp_header("cross-origin-opener-policy", "same-origin")
    |> put_resp_header("cross-origin-embedder-policy", "require-corp")
  end
end

IO.puts("Starting server at http://localhost:4000")
{:ok, _} = Plug.Cowboy.http(StaticServer, [], port: 4000)
Process.sleep(:infinity)

Run:

elixir server.exs

Open http://localhost:4000 and play!

COOP/COEP Headers for SharedArrayBuffer

If you’re embedding the game in a larger application (like Phoenix), you’ll need to set Cross-Origin headers for SharedArrayBuffer support:

# In your Phoenix endpoint or plug
plug :put_coop_coep_headers

defp put_coop_coep_headers(conn, _opts) do
  if String.starts_with?(conn.request_path, "/snake") or
     String.starts_with?(conn.request_path, "/wasm") do
    conn
    |> put_resp_header("cross-origin-opener-policy", "same-origin")
    |> put_resp_header("cross-origin-embedder-policy", "require-corp")
  else
    conn
  end
end

How It All Works

Let’s trace through what happens:

┌─────────────────────────────────────────────────────────────┐
│                     BROWSER                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  index.html loads → Popcorn.init() → AtomVM starts          │
│                           │                                 │
│                           ▼                                 │
│              SnakeGame.Application.start()                  │
│                           │                                 │
│                           ▼                                 │
│               SnakeGame.Start GenServer                     │
│              (registered as :main)                          │
│                           │                                 │
│         ┌─────────────────┴─────────────────┐               │
│         ▼                                   ▼               │
│  UI.setup() creates              Game GenServer starts      │
│  canvas via run_js                                          │
│         │                                                   │
│         ▼                                                   │
│  register_keyboard() sets up:                               │
│  - keydown listener → wasm.cast('main', key)                │
│  - setInterval → wasm.cast('main', 'tick')                  │
│                                                             │
│  ┌──────────────────────────────────────────┐               │
│  │        Every 150ms (from JS)             │               │
│  │               │                          │               │
│  │               ▼                          │               │
│  │    wasm.cast('main', 'tick')             │               │
│  │               │                          │               │
│  │               ▼                          │               │
│  │    Start.handle_info (wasm_message)      │               │
│  │               │                          │               │
│  │               ▼                          │               │
│  │    send(SnakeGame.Game, :tick)           │               │
│  │               │                          │               │
│  │               ▼                          │               │
│  │    Game moves snake, calls UI.render()   │               │
│  └──────────────────────────────────────────┘               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

The key insight: this is real Elixir running in your browser. The GenServer, pattern matching, processes, message passing… all of it works exactly as on the BEAM, just compiled to WebAssembly via AtomVM.

AtomVM Limitations to Remember

When building with Popcorn, keep these limitations in mind:

Feature Status Workaround
Process.send_after/3 Not supported Use JS setTimeout/setInterval
Tuple JSON encoding Not supported Convert to lists
ETS Not supported Use GenServer state or JS localStorage
Large integers Limited Stay within reasonable bounds
Some stdlib functions Varies Check AtomVM compatibility

Current Limitations

Popcorn is still early-stage. Keep in mind:

  • Bundle size: ~3MB for the runtime + stdlib (tree-shaking is coming)
  • Version lock: Only Elixir 1.17.3 / OTP 26.0.2 for now
  • Missing features: Some OTP features aren’t supported in AtomVM
  • Performance: AtomVM is optimized for embedded, not raw speed

For a game like Snake, these limitations don’t matter. For production apps, you’d want to evaluate carefully.

What’s Next?

You’ve just built a game in Elixir that runs entirely client-side. No server, no Phoenix, no WebSockets. Just BEAM semantics in the browser.

Some ideas to extend this:

  • High score persistence using localStorage via run_js
  • Sound effects with the Web Audio API
  • Touch controls for mobile (yeah it’s done)
  • Multiplayer using WebRTC (both players running their own Elixir!)

Resources


Popcorn opens up fascinating possibilities. Imagine LiveView apps where complex UI logic runs client-side in Elixir, or offline-first apps with local GenServers syncing when online. The BEAM is escaping the server. And that’s exciting.

Need help building the futur of the web ? We specialize in Elixir systems that goes beyond the server. Get in touch.