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
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
Quick Start
mix new snake_game
cd snake_game
Add Popcorn to mix.exs:
defp deps do
[
{:popcorn, github: "software-mansion/popcorn"}
]
end
Configure in config/config.exs:
import Config
config :popcorn,
start_module: SnakeGame.Start,
out_dir: "static/wasm"
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
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
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
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)
Run:
elixir server.exs
Open http://localhost:4000 and play!
Key Takeaways
- Real Elixir in the browser: GenServers, pattern matching, message passing - all works
-
JS interop via
run_js: Pass data as maps, receive callbacks viawasm.cast -
AtomVM limitations: No
Process.send_after, no ETS, no tuple JSON encoding - 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)