DEV Community

Cover image for Build a Snake Game in Elixir That Runs in Your Browser
Alembic Labs
Alembic Labs

Posted on • Originally published at alembiclabs.fr

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.


👉 Read the full tutorial on Alembic Labs

👉 Play the game live


What You'll Build

A classic Snake game running entirely in Elixir:

  • GenServer managing game state (yes, in the browser!)
  • Canvas rendering via JS interop
  • Keyboard event handling from Elixir
  • All the BEAM goodness: pattern matching, processes, message passing

Prerequisites

Popcorn currently requires specific versions:

  • Elixir 1.17.3
  • OTP 26.0.2
# .tool-versions
elixir 1.17.3-otp-26
erlang 26.0.2
Enter fullscreen mode Exit fullscreen mode

Quick Start

mix new snake_game
cd snake_game
Enter fullscreen mode Exit fullscreen mode

Add Popcorn to mix.exs:

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

Configure in config/config.exs:

import Config

config :popcorn,
  start_module: SnakeGame.Start,
  out_dir: "static/wasm"
Enter fullscreen mode Exit fullscreen mode

The Core: Game GenServer

defmodule SnakeGame.Game do
  use GenServer

  @grid_size 20

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

  def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
  def change_direction(dir), do: GenServer.cast(__MODULE__, {:change_direction, dir})
  def restart, do: GenServer.cast(__MODULE__, :restart)
  def get_state, do: GenServer.call(__MODULE__, :get_state)

  @impl true
  def init(_) do
    {:ok, new_game()}
  end

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

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

  # ... game logic (see full tutorial)
end
Enter fullscreen mode Exit fullscreen mode

Important: AtomVM doesn't support Process.send_after/3. We use JavaScript's setInterval instead.

JavaScript Interop

The magic happens with Popcorn.Wasm.run_js/2:

defmodule SnakeGame.UI do
  def render(state) do
    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');
        // ... render snake, food, score
      }
      """,
      %{snake: snake_cells, food_x: food_x, food_y: food_y, score: state.score}
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

Gotcha: Tuples can't be serialized to JSON. Convert {x, y} to [x, y] before passing to JavaScript.

Running It Locally

Build the WASM bundle:

mix deps.get
mix popcorn.cook
Enter fullscreen mode Exit fullscreen mode

Important: You need a server with COOP/COEP headers for SharedArrayBuffer support. Create server.exs:

# 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)
Enter fullscreen mode Exit fullscreen mode

Run:

elixir server.exs
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:4000 and play!

Key Takeaways

  1. Real Elixir in the browser: GenServers, pattern matching, message passing - all works
  2. JS interop via run_js: Pass data as maps, receive callbacks via wasm.cast
  3. AtomVM limitations: No Process.send_after, no ETS, no tuple JSON encoding
  4. COOP/COEP headers required: SharedArrayBuffer needs special headers

Resources


The BEAM is escaping the server. Imagine LiveView apps where complex UI logic runs client-side in Elixir, or offline-first apps with local GenServers syncing when online.

What would you build with Elixir in the browser? 👇

Top comments (0)