Snake moved in discrete steps on a grid. Clean, predictable, easy to reason about. What we’re building now is different. Flappy Bird is continuous – the bird is constantly falling, velocity accumulates, and the pipes come at you from the right with no fixed grid to think in.
This is where things start to feel like a real game.
Velocity and acceleration
In Part 2 we briefly mentioned that objects have a position and a velocity. Let’s be more precise about what that means in code.
Position is where the object is right now. Velocity is how fast position changes per frame. Acceleration is how fast velocity changes per frame.
// the three variables that describe any moving object
let y = 200; // current position
let vy = 0; // velocity (pixels per second, or per frame)
let ay = 0.5; // acceleration (gravity pulling down)
// each frame:
vy += ay; // gravity pulls velocity downward
y += vy; // velocity moves position
That’s it. That’s gravity in three lines. Every frame, vy gets a little more positive (more downward), so the bird falls faster and faster. When you press jump, you just set vy to a negative number – upward velocity that gravity then decelerates back to zero.
Run this in your head for a few frames:
| Frame | vy before | ay added | vy after | y change |
|---|---|---|---|---|
| 1 | 0 | +0.5 | 0.5 | +0.5 |
| 2 | 0.5 | +0.5 | 1.0 | +1.0 |
| 3 | 1.0 | +0.5 | 1.5 | +1.5 |
| jump | 1.5 | reset | -8 | -8 |
| after | -8 | +0.5 | -7.5 | -7.5 |
That acceleration-to-zero-to-positive arc is what gives jump mechanics their feel. Flappy Bird’s jump is abrupt and short. Mario’s is floatier. The numbers are different but the math is the same.
Game states
Our Snake game had a simple gameOver boolean. For Flappy Bird we need something slightly more structured. The game has three distinct states:
- IDLE: the start screen, bird bobs gently, waiting for input
- PLAYING: the game is running, pipes come, physics active
- DEAD: the bird hit something, brief freeze, then show score and restart prompt
Using a string or an enum-style object keeps things readable:
const STATE = {
IDLE: "idle",
PLAYING: "playing",
DEAD: "dead",
};
let gameState = STATE.IDLE;
Then in your loop:
function update(dt) {
if (gameState === STATE.PLAYING) {
updateBird(dt);
updatePipes(dt);
checkCollisions();
}
if (gameState === STATE.IDLE) {
updateIdleAnimation(dt);
}
}
This is much cleaner than a pile of booleans. And it scales – if you add a pause screen or a countdown, it’s just another state.
Procedural pipe generation
Flappy Bird’s pipes are infinite and random. You don’t pre-place them – you generate them as the game scrolls.
The approach: keep a list of pipes, add a new one every N pixels of scroll, and remove pipes that have gone off the left edge.
const pipes = [];
const PIPE_GAP = 140; // vertical space between top and bottom pipe
const PIPE_WIDTH = 52;
const PIPE_SPEED = 180; // pixels per second
const PIPE_SPAWN = 270; // horizontal distance between pipes
let distanceSinceLastPipe = 0;
Each pipe is an object with just x and gapY (the vertical center of the gap):
function spawnPipe() {
const gapY = 80 + Math.random() * (H - 200); // random gap center
pipes.push({ x: W, gapY });
}
function updatePipes(dt) {
distanceSinceLastPipe += PIPE_SPEED * dt;
if (distanceSinceLastPipe >= PIPE_SPAWN) {
spawnPipe();
distanceSinceLastPipe = 0;
}
pipes.forEach((pipe) => (pipe.x -= PIPE_SPEED * dt));
// remove pipes that went off screen
while (pipes.length && pipes[0].x < -PIPE_WIDTH) {
pipes.shift();
score++;
}
}
Removing from the front with shift() works because pipes always exit the screen in the order they were added.
Pause here and think through this: how would you detect if the bird (a small rectangle) has hit a pipe?
Each pipe has an x position and a gapY center. The gap has height PIPE_GAP. The top pipe goes from y=0 down to gapY - PIPE_GAP/2. The bottom pipe goes from gapY + PIPE_GAP/2 down to the bottom of the screen.
The bird has an x, y and a radius. Write the collision condition as a boolean expression before reading on.
This is called AABB collision (axis-aligned bounding box) and it's used everywhere. Worth figuring out yourself.
Collision detection
The basic idea for rectangle vs rectangle: two rectangles are overlapping if and only if they overlap on both the X axis and the Y axis simultaneously.
For the bird (circle approximated as a small box) vs a pipe:
function checkCollisions() {
// floor and ceiling
if (bird.y - bird.r < 0 || bird.y + bird.r > floorY) {
die();
return;
}
pipes.forEach((pipe) => {
const pipeLeft = pipe.x;
const pipeRight = pipe.x + PIPE_WIDTH;
const gapTop = pipe.gapY - PIPE_GAP / 2;
const gapBottom = pipe.gapY + PIPE_GAP / 2;
// is the bird horizontally overlapping with this pipe?
const horizontalOverlap =
bird.x + bird.r > pipeLeft && bird.x - bird.r < pipeRight;
if (!horizontalOverlap) return;
// if horizontally overlapping, check if bird is outside the gap
const hitTopPipe = bird.y - bird.r < gapTop;
const hitBottomPipe = bird.y + bird.r > gapBottom;
if (hitTopPipe || hitBottomPipe) {
die();
}
});
}
function die() {
gameState = STATE.DEAD;
// add a little screen shake here later
}
Building Flappy Bird
Everything above snaps together now. Set up the constants, build the state machine, draw the pipes and bird, handle the one input. Let's go.
Full setup
const canvas = document.getElementById("c");
const ctx = canvas.getContext("2d");
const W = 360;
const H = 640;
canvas.width = W;
canvas.height = H;
const STATE = { IDLE: "idle", PLAYING: "playing", DEAD: "dead" };
let gameState = STATE.IDLE;
const GRAVITY = 1400; // px/s^2
const JUMP_VEL = -420; // px/s (negative = up)
const PIPE_SPEED = 180;
const PIPE_GAP = 150;
const PIPE_WIDTH = 54;
const PIPE_SPAWN = 280;
const FLOOR_Y = H - 80;
const bird = { x: 80, y: H / 2, vy: 0, r: 14 };
const pipes = [];
let score = 0;
let bestScore = parseInt(localStorage.getItem("flappy_best") || "0");
let distanceSinceLastPipe = PIPE_SPAWN;
let idleTime = 0;
Using real units (px/s, px/s^2) with delta time gives consistent behavior across frame rates.
Drawing functions
function drawBackground() {
// sky gradient
const sky = ctx.createLinearGradient(0, 0, 0, FLOOR_Y);
sky.addColorStop(0, "#0a1628");
sky.addColorStop(1, "#1a3a5c");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, FLOOR_Y);
// ground
ctx.fillStyle = "#2d5a1b";
ctx.fillRect(0, FLOOR_Y, W, H - FLOOR_Y);
ctx.fillStyle = "#3d7a25";
ctx.fillRect(0, FLOOR_Y, W, 8);
}
function drawPipes() {
pipes.forEach((pipe) => {
const gapTop = pipe.gapY - PIPE_GAP / 2;
const gapBottom = pipe.gapY + PIPE_GAP / 2;
// pipe color with a lighter edge
ctx.fillStyle = "#4a9e2f";
// top pipe body
ctx.fillRect(pipe.x, 0, PIPE_WIDTH, gapTop);
// top pipe cap
ctx.fillStyle = "#5cb83a";
ctx.fillRect(pipe.x - 4, gapTop - 20, PIPE_WIDTH + 8, 20);
// bottom pipe
ctx.fillStyle = "#4a9e2f";
ctx.fillRect(pipe.x, gapBottom, PIPE_WIDTH, FLOOR_Y - gapBottom);
ctx.fillStyle = "#5cb83a";
ctx.fillRect(pipe.x - 4, gapBottom, PIPE_WIDTH + 8, 20);
});
}
function drawBird() {
const rotation = Math.max(-0.5, Math.min(bird.vy / 600, 1.2));
ctx.save();
ctx.translate(bird.x, bird.y);
ctx.rotate(rotation);
// body
ctx.beginPath();
ctx.arc(0, 0, bird.r, 0, Math.PI * 2);
ctx.fillStyle = "#fbbf24";
ctx.fill();
ctx.strokeStyle = "#d97706";
ctx.lineWidth = 2;
ctx.stroke();
// eye
ctx.beginPath();
ctx.arc(6, -4, 4, 0, Math.PI * 2);
ctx.fillStyle = "#fff";
ctx.fill();
ctx.beginPath();
ctx.arc(7, -4, 2, 0, Math.PI * 2);
ctx.fillStyle = "#1a1a1a";
ctx.fill();
// beak
ctx.beginPath();
ctx.moveTo(10, 1);
ctx.lineTo(18, 0);
ctx.lineTo(10, 5);
ctx.closePath();
ctx.fillStyle = "#f97316";
ctx.fill();
ctx.restore();
}
function drawHUD() {
if (gameState === STATE.PLAYING || gameState === STATE.DEAD) {
ctx.fillStyle = "#fff";
ctx.font = "bold 42px Space Grotesk";
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.fillText(score, W / 2, 30);
}
if (gameState === STATE.IDLE) {
ctx.fillStyle = "rgba(0,0,0,0.5)";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#fbbf24";
ctx.font = "bold 36px Space Grotesk";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("FLAPPY BIRD", W / 2, H / 2 - 50);
ctx.fillStyle = "#c8d1c1";
ctx.font = "14px JetBrains Mono";
ctx.fillText("tap or press space to start", W / 2, H / 2);
ctx.fillStyle = "#788571";
ctx.font = "12px JetBrains Mono";
ctx.fillText(`best: ${bestScore}`, W / 2, H / 2 + 30);
}
if (gameState === STATE.DEAD) {
ctx.fillStyle = "rgba(0,0,0,0.6)";
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "#fca5a5";
ctx.font = "bold 32px Space Grotesk";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("OUCH", W / 2, H / 2 - 60);
ctx.fillStyle = "#c8d1c1";
ctx.font = "16px JetBrains Mono";
ctx.fillText(`score: ${score}`, W / 2, H / 2 - 20);
ctx.fillText(`best: ${bestScore}`, W / 2, H / 2 + 10);
ctx.fillStyle = "#788571";
ctx.font = "13px JetBrains Mono";
ctx.fillText("tap or space to restart", W / 2, H / 2 + 50);
}
}
The ctx.save() and ctx.restore() around the bird drawing is important – ctx.rotate() affects everything drawn after it, on the whole canvas. save() snapshots the current transform state and restore() brings it back. Without them, every draw call after the bird would be rotated too.
Update functions
function updateBird(dt) {
bird.vy += GRAVITY * dt;
bird.y += bird.vy * dt;
}
function updatePipes(dt) {
distanceSinceLastPipe += PIPE_SPEED * dt;
if (distanceSinceLastPipe >= PIPE_SPAWN) {
pipes.push({ x: W, gapY: 120 + Math.random() * (FLOOR_Y - 240) });
distanceSinceLastPipe = 0;
}
pipes.forEach((p) => (p.x -= PIPE_SPEED * dt));
while (pipes.length && pipes[0].x < -PIPE_WIDTH) {
pipes.shift();
score++;
if (score > bestScore) {
bestScore = score;
localStorage.setItem("flappy_best", bestScore);
}
}
}
function checkCollisions() {
if (bird.y + bird.r >= FLOOR_Y || bird.y - bird.r <= 0) {
die();
return;
}
pipes.forEach((p) => {
const hor = bird.x + bird.r > p.x && bird.x - bird.r < p.x + PIPE_WIDTH;
if (!hor) return;
if (
bird.y - bird.r < p.gapY - PIPE_GAP / 2 ||
bird.y + bird.r > p.gapY + PIPE_GAP / 2
)
die();
});
}
function die() {
if (gameState !== STATE.PLAYING) return;
gameState = STATE.DEAD;
}
function jump() {
if (gameState === STATE.IDLE) {
gameState = STATE.PLAYING;
bird.vy = JUMP_VEL;
return;
}
if (gameState === STATE.DEAD) {
resetGame();
return;
}
bird.vy = JUMP_VEL;
}
function resetGame() {
bird.y = H / 2;
bird.vy = 0;
pipes.length = 0;
score = 0;
distanceSinceLastPipe = PIPE_SPAWN;
gameState = STATE.PLAYING;
bird.vy = JUMP_VEL;
}
The loop and input
let lastTime = 0;
function loop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
if (gameState === STATE.PLAYING) {
updateBird(dt);
updatePipes(dt);
checkCollisions();
}
if (gameState === STATE.IDLE) {
// gentle hovering idle animation
idleTime += dt;
bird.y = H / 2 + Math.sin(idleTime * 2.5) * 8;
}
drawBackground();
drawPipes();
drawBird();
drawHUD();
requestAnimationFrame(loop);
}
window.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "ArrowUp") {
e.preventDefault();
jump();
}
});
canvas.addEventListener("click", jump);
canvas.addEventListener(
"touchstart",
(e) => {
e.preventDefault();
jump();
},
{ passive: false },
);
requestAnimationFrame(loop);
Tuning the feel
Here’s the thing about Flappy Bird: it’s famously brutal. The original game has very specific numbers that make it feel that particular way. Your numbers will probably feel slightly different – and that’s fine, they’re yours.
Some things to mess with:
GRAVITYlower (like 900) makes it floatier and forgivingJUMP_VELmore negative (like -500) makes jumps more powerfulPIPE_GAPlarger (like 200) makes it way easierPIPE_SPEEDhigher makes it more intense
Spend 10 minutes changing just these numbers. You’ll get a feel for how each one changes the game. That’s basically what game feel tuning is.
save() is a slow memory leak.
- I understand velocity and acceleration (position += velocity, velocity += acceleration)
- I can implement a game state machine with more than 2 states
- I can generate infinite procedural obstacles
- I can implement AABB collision detection
- I know how to use ctx.save() and ctx.restore() for isolated transforms
- I built a playable Flappy Bird and tuned the feel myself
// up next — Part 04: Pixels Are Just Numbers
We've been drawing shapes and managing movement. In Part 4 we go deeper into what canvas actually stores -- raw pixel data. We'll cover drawImage, sprite sheets, and getImageData which lets you read and modify individual pixels. The project is the full Pac-Man game with actual ghost AI, a tile map and proper collision.