In May 2017 I started a Flappy Bird clone in Go with SDL2, got a bird sprite flapping its wings over a background, made 2 commits that same evening, an initial commit and then “wip” 12 minutes later, and did not touch it again for 9 years. Last week I decided I wanted it running in the browser.
This post explains why the Go version cannot compile to WebAssembly, how the Rust rewrite is split so the whole simulation tests natively, and where the unplanned time went.
1. 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. The moment cgo appears anywhere in the dependency graph, the wasm target
is gone; there is no flag to work around it.
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: 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 Rust rewrite of the rendering layer against the canvas 2D API, compiled with wasm-bindgen. That sounded like a big loss until I counted. The final “wip” snapshot was 519 lines, and roughly 80 of them were game logic; the rest was SDL initialization, teardown, and error plumbing. The port loses plumbing, not a game.
2. What the original actually did
Reading 9-year-old wip code is humbling. The game I remembered writing did not exist.
The bird moved with WASD, 2 pixels per keypress, in any direction. Jump()
set a speed field that nothing ever read; in the initial commit, the one
line that would have integrated it into position sat commented out, and the
“wip” commit 12 minutes later deleted it. Clicking the mouse cycled an
on-screen label through RUN, FLAP, and DEAD without changing anything else
about the world.
For those 12 minutes the initial commit’s Update() also called
spew.Dump(b.x, b.y) every frame, so the bird narrated its coordinates to
stdout 60 times a second; “wip” removed that too. Jump() took an
*sdl.Renderer argument it never used.
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 a 0.6-second lockout after death, so the panicked tap that killed you does not instantly restart the run.
3. The crate splits on one question: 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 2 are compiled only for wasm:
#[cfg(target_arch = "wasm32")]
mod app;
#[cfg(target_arch = "wasm32")]
mod render;
Each #[cfg] attribute gates the module declaration itself, so on any
target other than wasm32 the compiler never sees app or render at all.
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, so cargo test runs the entire simulation natively: no
browser, no headless anything. The crate builds as both cdylib and rlib
for exactly this reason.
13 unit tests cover the state machine, scoring exactly once per pipe, the death lockout, gap margins on spawned pipes, and the collision rules.
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, 1000 values all in range.
4. The timestep clamp is a correctness guard, not a tuning knob
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;
}
Each frame converts the elapsed milliseconds to seconds, adds them to acc,
and steps the simulation in fixed FIXED_DT slices until the accumulator is
spent. 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.
5. The 2017 sprite coordinates were wrong
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 3 sprites, 17x12 each, 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 2 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.
6. Shipping 56 KB of wasm behind a Worker
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.
2 known gaps remain. The score draws with fillText instead of the atlas
digits: 2 through 9 appeared to sit in a regular grid when I measured, but I
never found 0 and 1 near them and 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 unchanged in go/.
Source references
- notjrbauer/flappy-bird: the repo, with the Go original in
go/and the Rust port beside it - go-sdl2: the cgo SDL2 bindings the 2017 version rendered through
- flappy.bauer.gg: the deployed game