Building Flappy Bird in React — Canvas RAF, Gravity Physics, Pipe Collision, and Speed Progression
Why all game state lives in refs instead of useState, how dt-capped physics prevents tab-switch explosions, and how pipe forgiveness makes the game feel fair

Flappy Bird is deceptively simple to describe and deceptively tricky to implement correctly. The physics needs to feel right, the collision needs to be fair, and the game loop needs to stay stable when the browser tab loses focus. This is how I built it for Ultimate Tools, covering the four decisions that matter most.
Why all state lives in refs
React's useState is the wrong tool for a game loop. Every state update triggers a re-render, but the RAF loop runs 60 times per second — you don't want 60 re-renders per second. More critically, closures inside requestAnimationFrame capture state at the time they're created. If the loop reads birdY from a closure, it reads the value from when the loop was started, not the current value. This is the stale closure problem.
The solution: all mutable game state lives in refs.
const birdYRef = useRef(H / 2);
const birdVelRef = useRef(0);
const pipesRef = useRef<Pipe[]>([]);
const scoreRef = useRef(0);
const speedRef = useRef(BASE_SPEED);
const nextPipeRef = useRef(PIPE_INTVL);
const lastTsRef = useRef(0);
const statusRef = useRef<Status>("idle");
Refs update immediately without triggering re-renders, and closures always read the current value. useState is only used for values that need to appear in the React UI — score display, high score, and game status for overlays.
Physics constants
All physics values are module-level constants, not magic numbers scattered through the loop:
const GRAVITY = 1400; // px/s²
const JUMP_VEL = -460; // px/s (negative = upward)
const BASE_SPEED = 210; // px/s (horizontal pipe speed)
const SPEED_INC = 18; // px/s added every 5 pipes
const PIPE_GAP = 155; // vertical gap between top and bottom pipe
const PIPE_INTVL = 1.55; // seconds between pipe spawns
GRAVITY = 1400 px/s² feels close to the original game's weight. Real Earth gravity would be ~980 px/s² at 1px = 1mm, but games use exaggerated gravity to make jumps feel snappy rather than floaty.
JUMP_VEL = -460 gives the bird enough upward momentum to clear roughly two-thirds of the screen height before gravity brings it back down. These two values are tuned together — changing one requires re-tuning the other.
The game loop
The loop runs via requestAnimationFrame and receives a high-resolution timestamp:
const loop = useCallback((ts: number) => {
if (statusRef.current !== "playing") return;
const dt = Math.min((ts - lastTsRef.current) / 1000, 0.05);
lastTsRef.current = ts;
// Bird physics
birdVelRef.current += GRAVITY * dt;
birdYRef.current += birdVelRef.current * dt;
// ...pipe logic, collision, draw
rafRef.current = requestAnimationFrame(loop);
}, [draw]);
The dt cap is critical. Math.min(..., 0.05) clamps the time delta to 50ms maximum. Without it, switching to another browser tab for 10 seconds and switching back produces a dt of 10 — the bird teleports through the ground and the physics state corrupts. The cap means the game pauses gracefully when tabbed out rather than trying to simulate 10 seconds of physics in one frame.
The physics integration is Euler: velocity += acceleration × dt, position += velocity × dt. Simple and sufficient for this kind of game — the timestep is short enough that integration error doesn't accumulate visibly.
Pipe spawning and scoring
Pipes are objects with an x position, a top height, and a scored flag:
type Pipe = { x: number; topH: number; scored: boolean };
On each frame, the spawn timer counts down:
nextPipeRef.current -= dt;
if (nextPipeRef.current <= 0) {
const minTop = 55;
const maxTop = H - GROUND_H - PIPE_GAP - 55;
const topH = minTop + Math.random() * (maxTop - minTop);
pipesRef.current.push({ x: W + 10, topH, scored: false });
nextPipeRef.current = PIPE_INTVL;
}
The 55px margins on both ends keep the gap from appearing so high or so low that it's unplayable. PIPE_GAP = 155 is the vertical opening — wide enough to be passable, narrow enough to require attention.
Scoring happens when the pipe's right edge passes the bird's x position:
if (!p.scored && p.x + PIPE_W < BIRD_X) {
p.scored = true;
newScore++;
if (newScore % 5 === 0) {
speedRef.current = Math.min(
BASE_SPEED + SPEED_INC * 6,
speedRef.current + SPEED_INC
);
}
}
Speed increases by 18 px/s every 5 pipes, capped at BASE_SPEED + SPEED_INC × 6 = 318 px/s. The cap prevents the game from becoming physically impossible at high scores.
Off-screen pipes are pruned each frame: pipesRef.current = pipesRef.current.filter(p => p.x > -PIPE_W - 10).
Collision detection with forgiveness
Naive circular collision against the pipe rectangles feels unfair — the bird visually clips through a corner that shouldn't kill it. The fix is a small forgiveness margin on the pipe hit box:
// Pipes (slight forgiveness with -4px inset on entry, +2px on gap)
if (BIRD_X + BIRD_R > p.x + 5 && BIRD_X - BIRD_R < p.x + PIPE_W - 5) {
if (by - BIRD_R < p.topH - 2 || by + BIRD_R > p.topH + PIPE_GAP + 2) {
hit = true;
}
}
The horizontal check uses p.x + 5 and p.x + PIPE_W - 5 — 5px inset on each side. The vertical check uses -2 and +2 — 2px forgiveness at the gap edges. This means the bird can visually graze the pipe cap without dying, which feels consistent with how the original game worked.
Ground collision is straightforward: by + BIRD_R >= H - GROUND_H. Ceiling collision: by - BIRD_R <= 0.
Bird rotation
The bird rotates based on its velocity — nose up when rising, nose down when falling:
const angle = Math.max(-0.45, Math.min(0.9, vel / 850)) * Math.PI;
ctx.save();
ctx.translate(BIRD_X, by);
ctx.rotate(angle);
// draw bird centered at (0, 0)
ctx.restore();
Dividing velocity by 850 maps the velocity range to a normalized value. Clamping to [-0.45, 0.9] prevents extreme rotation — the bird tilts up slightly at maximum jump velocity and tilts down noticeably when falling fast, but never flips upside down.
The ctx.save() / ctx.restore() sandwich is essential — without it, the rotation transform accumulates across frames and the bird spirals.
Bird drawing
The bird is drawn from primitives: a circle body, an ellipse wing, a circular eye with a pupil, and a triangle beak.
// Body
ctx.fillStyle = "#ffd700";
ctx.beginPath();
ctx.arc(0, 0, BIRD_R, 0, Math.PI * 2);
ctx.fill();
// Wing
ctx.fillStyle = "#ffb300";
ctx.beginPath();
ctx.ellipse(-3, 5, 8, 5, 0.3, 0, Math.PI * 2);
ctx.fill();
// Eye white + pupil
ctx.fillStyle = "#fff";
ctx.arc(5, -4, 5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#1a1a1a";
ctx.arc(6.5, -4, 2.5, 0, Math.PI * 2); ctx.fill();
// Beak (triangle)
ctx.fillStyle = "#e87722";
ctx.beginPath();
ctx.moveTo(12, -1); ctx.lineTo(19, -4); ctx.lineTo(19, 3);
ctx.closePath(); ctx.fill();
Everything is drawn relative to (0, 0) because the canvas transform already puts (0, 0) at the bird's center position. That's why the rotation works correctly.
Touch and keyboard input
Space, ArrowUp, and W all trigger a jump on keydown. On mobile, onTouchStart on the canvas element handles tap-to-flap with e.preventDefault() to suppress the 300ms mobile tap delay:
// Keyboard
window.addEventListener("keydown", (e) => {
if (["Space", "ArrowUp", "KeyW"].includes(e.code)) {
e.preventDefault();
jump();
}
});
// Touch (on canvas element)
onTouchStart={(e) => { e.preventDefault(); jump(); }}
Jump applies JUMP_VEL directly to the velocity ref — it doesn't accumulate, it replaces:
const jump = () => {
if (statusRef.current === "idle") { startGame(); return; }
if (statusRef.current === "dead") return;
birdVelRef.current = JUMP_VEL; // replace, don't add
};
Replacing rather than adding means rapid tapping doesn't launch the bird off-screen — each jump resets the upward velocity to exactly JUMP_VEL.
High score persistence
High score is read from localStorage on mount and written on death when the current score beats it:
useEffect(() => {
const hs = parseInt(localStorage.getItem("flappy-hs") ?? "0");
if (hs) setHighScore(hs);
}, []);
// On death:
const hs = parseInt(localStorage.getItem("flappy-hs") ?? "0");
if (scoreRef.current > hs) {
localStorage.setItem("flappy-hs", String(scoreRef.current));
setHighScore(scoreRef.current);
}
Score is read and written only on game-over, not on every point — no unnecessary localStorage I/O in the hot path.
Play it: Flappy Bird at Ultimate Tools — free, no account, works on mobile.
