I was procrastinating on a Saturday afternoon when I decided to open a blank HTML file and try to rebuild the most infuriating mobile game of 2014 — Flappy Bird — from scratch. No game engine. No library. No npm install. Just a canvas, vanilla JavaScript, and around 120 lines of code.
Here's what I learned about gravity, collision detection, and why one-button games are actually brilliant.
Why Flappy Bird is a Perfect Learning Project
Every game mechanic in Flappy Bird maps to a fundamental concept:
- The bird falling → gravity simulation
- Tapping to flap → velocity impulse
- Scrolling pipes → object pooling with arrays
- Hitting a pipe → AABB collision detection
- Counting score → event-driven state
Five concepts, 120 lines, and you'll understand the physics engine underneath every platformer you've ever played.
Step 1: The Canvas and the Game Loop
The foundation of every real-time browser game is requestAnimationFrame. The browser calls your function roughly 60 times per second, and each time you:
- Update the game state (move stuff)
- Draw the new state
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
That's it. The entire structure of the game lives inside update() and draw(). Keeping them separate is the key — logic never talks to pixels directly.
Step 2: Gravity is Just Two Lines
This was my biggest "oh" moment when I first learned game dev. Gravity doesn't work like a jump. It's a constant downward acceleration applied every single frame. The bird has a vertical velocity (vy) that gets a little more positive (downward) each tick:
bird.vy += GRAVITY; // 0.45 — tiny push down every frame
bird.vy = Math.min(bird.vy, MAX_FALL); // cap so it doesn't go insane
bird.y += bird.vy; // actually move the bird
And when the player taps? You don't "jump" — you blast the velocity upward with a single impulse:
function flap() {
bird.vy = FLAP_STRENGTH; // -8 (negative = upward on canvas)
}
That's your entire physics engine. Two lines of arithmetic. The same principle powers Mario's jump, Celeste's dash, and every platformer ever written.
Step 3: Scrolling Pipes
The pipes don't actually scroll — the world moves. Each pipe is just an object with an x position and a gapY position. Every frame, x decreases by the scroll speed. When x goes off screen, we remove it. When enough frames pass, we spawn a new one:
if (frame % PIPE_INTERVAL === 0) {
const gapY = 110 + Math.random() * (H - 230);
pipes.push({ x: W, gapY, scored: false });
}
pipes.forEach(p => p.x -= SPEED);
pipes = pipes.filter(p => p.x > -PIPE_W);
Three lines. Push a new pipe, slide all pipes left, remove dead ones. The random gapY is what makes each run feel different — the gap center varies while the gap size stays constant, so the difficulty is consistent but the pattern is never the same.
Step 4: Collision Detection — AABB
AABB stands for Axis-Aligned Bounding Box. It sounds scary until you draw it out. You have two rectangles. They overlap if and only if they are NOT separated on either axis. The check is:
const bx = bird.x - bird.r, by = bird.y - bird.r;
const bw = bird.r * 2, bh = bird.r * 2;
if (bx < p.x + PIPE_W && bx + bw > p.x) {
if (by < p.gapY - GAP/2 || by + bh > p.gapY + GAP/2) {
die();
}
}
The bird is circular, but I'm approximating it with a square bounding box. That's totally fine — it's the same trick every 2D game uses. Perfect circle-vs-rectangle collision exists, but AABB is fast, simple, and forgiving enough that players never notice the difference.
Step 5: Scoring Is an Event
Score increments exactly once per pipe — when the bird's x passes the pipe's right edge for the first time. The scored flag on each pipe prevents double-counting:
pipes.forEach(p => {
if (!p.scored && p.x + PIPE_W < bird.x) {
score++;
p.scored = true;
}
});
This is event detection without an event system. You're polling for a condition change each frame and firing a side effect once. It's the same pattern you'd use for "player enters zone" triggers in a bigger game.
What Actually Makes Flappy Bird Hard
When I was playing the original, I always assumed it was the controls that were punishing. Building it myself, I realised the difficulty is entirely engineered through constants:
- GAP size — smaller gap = harder
- SPEED — faster scroll = less reaction time
- GRAVITY — stronger pull = fewer frames to correct a mistake
- FLAP_STRENGTH — weaker flap = less upward clearance
In my version I kept the gap at 130px and scroll at 2.8 pixels/frame. The original was something like 90px gap at 3+ pixels/frame. That 40px gap difference is the entire reason Dong Nguyen's version was infamous.
The Full Game in Under 130 Lines
The final HTML file is self-contained — no build step, no dependencies, no server. Canvas setup, constants, init function, flap function, update loop, draw function, and the click/keydown event listeners. Open it in any browser and it works.
The draw function is the most fun part. I gave the bird a tilt based on its velocity — rotating it slightly forward when falling and slightly back when flapping. It's a one-liner that makes the whole thing feel alive:
const tilt = Math.max(-0.4, Math.min(0.5, bird.vy * 0.04));
ctx.rotate(tilt);
That's a Math.clamp pattern (JS doesn't have a native clamp, but you can nest min/max). The tilt is proportional to velocity, capped so it never looks absurd.
What I'd Do Differently
If I was building this for production (hah), I'd:
- Use a sprite sheet instead of canvas-drawn shapes — the bird would look much better
- Add parallax backgrounds — a second sky layer scrolling at 60% speed gives depth for free
- Track high score in localStorage — one line and it adds massive replay value
For a learning project though? The canvas-drawn bird is actually better. You see every pixel you're responsible for.
Play It and Read the Code
The live game is part of my GameFromZero series — 50 browser games, one per day, zero libraries. Day 7 is Flappy Bird.
- Play it: dev48v.infy.uk/gamefromzero.php
- Full source: github.com/dev48v/dev48v
Each day has three tabs: LOOK (just play), UNDERSTAND (step-by-step breakdown with code), and BUILD (write it yourself from numbered steps).
Tomorrow: Frogger. Lanes of traffic + a river of logs + a frog that refuses to die easily. Let's see how far I get.
Day 7 of #GameFromZero. Building 50 browser games from scratch, one concept at a time.
Top comments (0)