Spielwerk 11: Flappy Bird

2026-11-01
Spielwerk 11: Flappy Bird - Teaser

A bird, gravity, one flap per tap, and pipes coming in from the right. The first game in the series where something fast must not pass through something thin, so the new toolbox module is Aabb: box collisions with a swept test that checks the whole path of a frame, not just where the bird ends up.

Play it · Source: collection-spielwerk/11-flappy-bird · Toolbox 5.6.0 or later

The game

The bird hovers until the first tap; that tap is also the first flap. From then on gravity pulls it down, every tap sets it flying upwards again, and pipe pairs with a gap at a seeded height come in from the right. Each pair passed is a point, and every point makes the world a little faster. Touch a pipe, the ground or the top edge and it is "Game Over"; the best score of the session stays in the bar ("Beste", best).

New in the toolbox: Aabb

An AABB is an axis-aligned bounding box: a rectangle whose edges are parallel to the axes, in the toolbox a RectI with left, top, width, height. Almost everything in a 2D game can be wrapped in one, and for boxes the overlap test is four comparisons:

/** Boxes overlap (touching edges do not count). */
export function aabb_overlaps(a: RectI, b: RectI): boolean {
    return a.left < b.left + b.width && a.left + a.width > b.left
        && a.top < b.top + b.height && a.top + a.height > b.top;
}

The comparisons are strict on purpose: a bird resting exactly on the ground touches it, it is not inside it.

The problem. That test only knows the moment. If the bird moves further in one frame than a pipe wall is thick, it can be in front of the wall in one frame and behind it in the next, without ever overlapping it. That is called tunnelling, and it happens exactly when it hurts most: at high speed, on a stutter, after a tab was in the background.

The idea. A swept test asks about the whole move instead: the box moves by delta; at which fraction of that move, between 0 and 1, does it first touch the wall, and from which side? The answer comes per axis. Moving right, the x spans start to overlap when the box's right edge reaches the wall's left edge, and stop when its left edge passes the wall's right edge. Same for y. The boxes overlap on both axes from the later of the two entry times until the earlier of the two exit times:

// src/physics/aabb/Aabb.ts, aabb_sweep(), trimmed
const push = aabb_resolve(mover, wall);           // already inside? a hit at time 0
if (push !== null) {
    return { normal: { x: Math.sign(push.x), y: Math.sign(push.y) }, time: 0, overlap: push };
}
const x = axis_times(mover.left, mover.width, wall.left, wall.width, delta.x);
const y = axis_times(mover.top, mover.height, wall.top, wall.height, delta.y);
const entry = Math.max(x.entry, y.entry);         // both axes overlap from here ...
const exit = Math.min(x.exit, y.exit);            // ... until here
if (entry >= exit || entry < 0 || entry > 1) {
    return null; // the path misses the wall
}
const normal = x.entry > y.entry
    ? { x: delta.x > 0 ? -1 : 1, y: 0 }           // x was met last: a side face
    : { x: 0, y: delta.y > 0 ? -1 : 1 };          // otherwise floor or ceiling
return { normal, time: entry, overlap: { x: 0, y: 0 } };

axis_times divides the distances by delta; an axis without movement overlaps either the whole time or never. The axis that came into overlap last says which face was hit; that is the normal, pointing away from the wall.

The module has five functions:

aabb_overlaps(a, b)               // overlap right now
aabb_resolve(mover, wall)         // shortest push out of the wall, or null
aabb_sweep(mover, delta, wall)    // Hit { normal, time, overlap } or null
aabb_move(mover, delta, walls)    // move against many walls and slide along them
aabb_union(boxes)                 // the box around several boxes

aabb_move is built on the sweep: stop at the first hit, drop the part of delta that points into the wall, move the rest again. That sliding is what a platformer hero needs on a floor; Flappy Bird only needs the sweep.

How the game uses it. The pipes never move; the bird flies, and the renderer scrolls the world past it. So the sweep is exactly the bird's move in this frame, tested against every pipe box, the ground and a ceiling box above the screen. The earliest hit wins and puts the bird back to the moment of the touch:

// src/game/Game.ts, fly(), trimmed
const before = this.bird_box;
integrate(this, { x: 0, y: WORLD.gravity }, delta_seconds);
const delta = { x: this.position.x - (before.left + WORLD.bird_box / 2), y: this.position.y - (before.top + WORLD.bird_box / 2) };

let first: Hit | null = null;
for (const [wall, kind] of candidates) {          // ground, ceiling, every pipe box
    const hit = aabb_sweep(before, delta, wall);
    if (hit !== null && (first === null || hit.time < first.time)) {
        first = hit;
    }
}
if (first !== null) {
    // back to the moment of the touch: the bird rests on the wall it hit
    this.position.x = before.left + WORLD.bird_box / 2 + delta.x * first.time + first.overlap.x;
    this.position.y = before.top + WORLD.bird_box / 2 + delta.y * first.time + first.overlap.y;
    return true;                                  // -> 'over'
}

The test. The toolbox test (src/physics/aabb/Aabb.test.ts) falls through a floor one pixel thick in a single step and still hits it:

it("hits the top of a floor when falling, even through it in one step", () => {
    const floor = box(0, 100, 1000, 1);
    const hit = aabb_sweep(box(0, 0, 10, 10), { x: 0, y: 900 }, floor);
    expect(hit?.time).toBeCloseTo(0.1);
    expect(hit?.normal).toEqual({ x: 0, y: -1 });
});

The game's own test does the same with the bird: one step long enough to carry it from well before a pipe to well behind it. With aabb_overlaps the bird would come out on the other side; with the sweep it ends touching the pipe's left face, not inside it.

Path of the module: src/physics/aabb/Aabb.ts.

From earlier steps

The scaffold, start screen, logo intro and scaling are explained once in Spielwerk 00: The workbench. "Neustart" is a toolbox Button on a UiPage, styled by theme rules. What this step first wrote by hand for the scene behind the start screen and the margins around the layout is part of the canvas game shell now; the workbench lists it under "Extracted along the way".

Embed it

<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-flappy-bird"></script>
<tce-flappy-bird init-seed="4711"></tce-flappy-bird>
Attribute Values Default
init-seed whole number; the first course, every next round is seed + 1 random
init-intro true / false; play the GO logo first true

Next

Previous: Spielwerk 10: Hau den Maulwurf · Next: Spielwerk 12: Side-Scroller · All steps

Comments

captcha