Porting Flappy Bird from Go/SDL2 to Rust and WebAssembly
[rust]In May 2017 I started a Flappy Bird clone in Go with SDL2. I got as far as a bird sprite flapping its wings over a background, committed “wip” that same evening, and did not touch it again for nine years. Last week I decided I wanted it running in the browser. This is what that took.
Why a recompile was off the table
The Go version renders through
go-sdl2, which is cgo bindings to native
SDL2. Go’s WebAssembly target (GOOS=js GOARCH=wasm) does not support cgo.
Not awkwardly, not with flags: at all. The moment cgo appears anywhere in the
dependency graph, the wasm target is gone. Emscripten, the usual escape hatch
for SDL2 projects, is a C/C++ toolchain and does not take Go either.
Apparently 2017 me suspected trouble here, because the old main.go opens
with a bare import "C" and ends with a commented-out //export main2 shim,
half a plan to embed the whole game in a C host someday. It would not have
helped.
So the plan became a rewrite of the rendering layer against the canvas 2D API, in Rust, compiled with wasm-bindgen. That sounded like a big loss until I counted. The original was 519 lines, and roughly 80 of them were game logic. The rest was SDL initialization, teardown, and error plumbing.
What the original actually did
Reading nine-year-old wip code is humbling. The game I remembered writing did
not exist. The bird moved with WASD, two pixels per keypress, in any
direction. Jump() set a speed field that nothing ever read; the one line
that would have integrated it into position was commented out. Clicking the
mouse cycled an on-screen label through RUN, FLAP, and DEAD without changing
anything else about the world. Update() called spew.Dump(b.x, b.y) every
frame, so the bird narrated its coordinates to stdout sixty times a second.
Jump() also took an *sdl.Renderer argument it never used, for reasons lost
to history.
So the port is where the actual game got written: gravity plus a flap impulse, with the bird’s rotation easing toward its velocity so it noses up on a flap and tips over as the fall accelerates. AABB collision against a hitbox inset 8 by 6 pixels from the drawn sprite, because near misses that count as hits feel terrible. The ceiling clamps instead of killing you, matching the real game. And there is a 0.6 second lockout after death, so the panicked tap that killed you does not instantly restart the run.
The Rust side
The crate splits along one line: does this module need a DOM. config holds
constants and sprite-atlas coordinates, game holds the state machine,
physics, collision, and scoring, storage persists the best score, render
draws to the canvas, and app boots everything and owns the frame loop. The
last two are compiled only for wasm:
#[cfg(target_arch = "wasm32")]
mod app;
#[cfg(target_arch = "wasm32")]
mod render;
storage has a wasm implementation over localStorage and a host stand-in
that returns 0 and drops writes. Everything else is plain Rust with no
web-sys in sight, which means cargo test runs the entire simulation
natively. Thirteen unit tests cover the state machine, scoring exactly once
per pipe, the death lockout, gap margins on spawned pipes, and the collision
rules. No browser, no headless anything. The crate builds as both cdylib and
rlib for exactly this reason.
Physics run on a fixed timestep, 120 steps per second, with the usual accumulator inside the requestAnimationFrame callback:
let dt = ((now - last) / 1000.0).min(0.25);
acc += dt;
while acc >= FIXED_DT {
game.borrow_mut().step(FIXED_DT);
acc -= FIXED_DT;
}
The .min(0.25) clamp matters more than it looks. Without it, a backgrounded
tab accumulates minutes of wall time, and the moment you come back the loop
fast-forwards the bird into the ground before the tab has finished repainting.
Pipe gaps come from a hand-rolled xorshift with a fixed seed. A rand crate for one stream of unit-interval floats was not worth the bytes, and the fixed seed makes the RNG test trivial: same seed, same sequence, a thousand values all in range.
Re-measuring the sprite sheet
Most of the unplanned time went into the atlas, because the port rendered the original’s sprite coordinates faithfully and they turned out to be wrong.
The bird frames are three 17x12 sprites at x = 3, 31, 59 in row 491 of the 512x512 atlas. The 2017 code read 20x20 boxes on a 28 pixel stride starting at (0, 490), which grabs each frame plus a ring of its neighbors’ padding. The ground is the striped 168x56 base strip at (292, 0), tiled horizontally with the tiles overlapping by a pixel so fractional scroll offsets do not leave a hairline seam. The original had been slicing a flat green band out of the background image instead, so the ground technically scrolled but you could not see it move.
pipe.png has structure the original ignored. It is 52x320: a 24 pixel cap at
full width, then a body inset two pixels per side. The old code stretched the
entire texture to whatever height the pipe needed, squashing the cap. The port
draws the cap at a fixed height, stretches only the body, and gets the top
pipe by mirroring the same sprite with a canvas transform, a
scale(1.0, -1.0) about the gap edge.
I ended up re-measuring every rect off the alpha channel of the PNG rather than trusting any number I had written down in 2017.
Shipping it
build.sh runs cargo, then wasm-bindgen, then wasm-opt if it is installed.
With opt-level = "z", LTO, panic = "abort", and symbols stripped, the
module comes out at 56 KB raw, 26 KB gzipped, plus 22 KB of wasm-bindgen JS
glue. It deploys as a Cloudflare Worker whose script is a thin wrapper over
the static asset store. The Worker’s main job is forcing application/wasm on
the module; without that content type the browser silently drops from
streaming instantiation to buffering the whole file first. The built output is
committed, so the CI deploy is a checkout and a wrangler call, no Rust
toolchain in the workflow.
Two known gaps. The score draws with fillText instead of the atlas digits,
because the atlas has a clean digit grid for 2 through 9 but 0 and 1 live
somewhere else entirely and I stopped hunting. And there is no sound.
You can play it in the browser. The Go original and
the Rust port sit side by side
in the repo, the 2017 code
preserved in go/ as a monument.