DEV Community

Cover image for Building Flappy Bird in React — Canvas + RAF Physics, Gravity, Pipe Generation, and Speed Progression
Shaishav Patel
Shaishav Patel

Posted on

Building Flappy Bird in React — Canvas + RAF Physics, Gravity, Pipe Generation, and Speed Progression

Flappy Bird is a physics-based one-button game. That puts it in the Canvas + requestAnimationFrame category — the same pattern as Snake and Brick Breaker. All mutable game state lives in refs, not React state, to avoid stale closures in the animation loop.

Here's how we built the Flappy Bird game on Ultimate Tools.


Why Refs, Not State

The RAF callback captures its closure at creation time. If birdY is a React state variable, the callback always sees the value from when it was created — the stale closure problem.

The fix: put all mutable game values in refs. React state is only used for what needs to trigger a re-render: status, score, and best.

const birdYRef   = useRef(250);
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');
const rafRef     = useRef(0);

// React state — only for rendering
const [score, setScore] = useState(0);
const [status, setStatus] = useState<Status>('idle');
const [best, setBest] = useState(() => Number(localStorage.getItem('flappy-best') || 0));
Enter fullscreen mode Exit fullscreen mode

Physics Constants

const W = 360, H = 500, GROUND_H = 60
const BIRD_X = 80, BIRD_R = 14         // bird fixed X, radius
const GRAVITY = 1400                    // px/s²
const JUMP_VEL = -460                  // px/s, negative = upward
const PIPE_W = 58, PIPE_GAP = 155      // pipe width, gap between top/bottom
const PIPE_INTVL = 1.55                // seconds between pipe spawns
const BASE_SPEED = 210                 // initial pipe scroll speed px/s
const SPEED_INC = 18                   // speed added every 5 pipes cleared
Enter fullscreen mode Exit fullscreen mode

These were tuned empirically — start with textbook values and adjust until the game feels fair but challenging.


The RAF Loop

function loop(ts: number) {
    const dt = Math.min((ts - lastTsRef.current) / 1000, 0.05); // cap delta at 50ms
    lastTsRef.current = ts;

    // Bird physics
    birdVelRef.current += GRAVITY * dt;
    birdYRef.current   += birdVelRef.current * dt;

    // Move + cull pipes
    pipesRef.current = pipesRef.current
        .map(p => ({ ...p, x: p.x - speedRef.current * dt }))
        .filter(p => p.x + PIPE_W > -10);

    // Spawn new pipe
    nextPipeRef.current -= dt;
    if (nextPipeRef.current <= 0) {
        nextPipeRef.current = PIPE_INTVL;
        const minGapY = 90;
        const maxGapY = H - GROUND_H - PIPE_GAP - 90;
        const gapY = minGapY + Math.random() * (maxGapY - minGapY);
        pipesRef.current.push({ x: W + 10, gapY, scored: false });
    }

    // Score + speed progression
    for (const p of pipesRef.current) {
        if (!p.scored && p.x + PIPE_W < BIRD_X) {
            p.scored = true;
            scoreRef.current += 1;
            setScore(scoreRef.current);                        // trigger React render
            if (scoreRef.current % 5 === 0) {
                speedRef.current = Math.min(
                    BASE_SPEED + SPEED_INC * 6,               // cap at 6 increments
                    speedRef.current + SPEED_INC
                );
            }
        }
    }

    // Collision detection
    if (checkCollision()) {
        handleDeath();
        return;
    }

    draw();
    rafRef.current = requestAnimationFrame(loop);
}
Enter fullscreen mode Exit fullscreen mode

The dt cap at 0.05 (50ms) prevents the game from warping when the tab is backgrounded and the RAF fires after a long pause.


Collision Detection

Bird is a circle (center BIRD_X, birdY, radius BIRD_R). Pipes are rectangles. We use a 5px inset on the hitbox for a forgiving feel:

function checkCollision(): boolean {
    const bY = birdYRef.current;

    // Ground and ceiling
    if (bY + BIRD_R >= H - GROUND_H) return true;
    if (bY - BIRD_R <= 0) return true;

    for (const p of pipesRef.current) {
        const inXRange = BIRD_X + BIRD_R - 5 > p.x + 5 && BIRD_X - BIRD_R + 5 < p.x + PIPE_W - 5;
        if (!inXRange) continue;
        // Top pipe: from 0 to gapY
        if (bY - BIRD_R + 5 < p.gapY) return true;
        // Bottom pipe: from gapY + PIPE_GAP to canvas bottom
        if (bY + BIRD_R - 5 > p.gapY + PIPE_GAP) return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

Bird Rotation

The bird rotates based on vertical velocity — tilts up when rising, nose-down when falling:

const angle = Math.max(-0.45, Math.min(0.9, birdVelRef.current / 850)) * Math.PI;
ctx.save();
ctx.translate(BIRD_X, birdYRef.current);
ctx.rotate(angle);
// draw bird centered at 0,0
ctx.restore();
Enter fullscreen mode Exit fullscreen mode

Clamping to [-0.45, 0.9] radians prevents extreme angles that look unrealistic.


Input — Space, Click, and Touch

All three inputs call the same flap() function:

function flap() {
    if (statusRef.current === 'idle') startGame();
    if (statusRef.current !== 'playing') return;
    birdVelRef.current = JUMP_VEL;
}

// Keyboard
window.addEventListener('keydown', e => {
    if (['Space', 'KeyW', 'ArrowUp'].includes(e.code)) flap();
});

// Canvas click
canvasRef.current?.addEventListener('click', flap);

// Touch
canvasRef.current?.addEventListener('touchstart', e => { e.preventDefault(); flap(); });
Enter fullscreen mode Exit fullscreen mode

Result

The full component is ~280 lines — Canvas 2D for rendering, RAF loop with delta time for physics, all mutable state in refs. No game engine, no physics library.

Play Flappy bird game live

Top comments (0)