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
-
Arrow function syntax: Use
({ args }) => { ... }notfunction(args) { ... } -
Destructuring:
wasmandargsare destructured from the parameter object -
Args are a map: Pass
%{key: value}directly, notbindings: %{...} -
Access via
args.key: Notargs.bindings.key -
Call Elixir via
wasm.cast: Usewasm.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:
-
Reduces flickering: Multiple sequential
run_jscalls (clear, draw food, draw snake) cause visible flickering because each call is a separate JS execution - 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:
-
Import the guard:
import Popcorn.Wasm, only: [is_wasm_message: 1] -
Pattern match with
when is_wasm_message(raw_msg) -
Use
Wasm.handle_message!/2with a callback function -
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
localStorageviarun_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.