TinyGames · how it works
One button, one bird, and a gap that never quite lines up. Tap to flap, do not touch a pipe, do not touch the ceiling or the floor. The gap tightens as you go.
Open index.html. No build step, no dependencies, no images — the bird is drawn.
flutter/
index.html markup
style.css the frame around the canvas
js/flight.js the fall, the course generator and the arithmetic
- no pixels, no engine, no DOM
js/flutter.js the playable layer
test-flight.js node test-flight.js
Pick each gap's height independently at random and sooner or later two consecutive gaps sit further apart than the bird can climb or fall between them in the distance available. The player dies to a course that was never passable, and it looks exactly like their own mistake.
It is not rare. On this game's numbers, 44.7% of consecutive pairs produced by an unconstrained generator are unflyable — nearly one pipe in two.
So the next gap's centre is confined, by construction, to the vertical reach the bird actually has over one pipe spacing:
| over one spacing (1.00s) | |
|---|---|
| the bird can climb | 23.50 units |
| the bird can fall | 23.79 units |
| the centres have room for | 68.0 units |
Both reaches are about a third of the room available, so the constraint is doing real work rather than decorating. Across 237,000 generated pairs the course gets to 99.8% of the climb and 99.7% of the dive — it approaches the limit without ever crossing it.
The flap cooldown is what makes this possible at all. Without a least time between two flaps a player could flap arbitrarily often, "how high can it climb" has no answer, and there is nothing to constrain the course to. It is not an anti-mashing measure that happens to be there; it is load-bearing.
The reach is worked out conservatively in both directions:
The usual pair of lines
velocity += gravity * dt;
position += velocity * dt;
traces a different arc for every step size. The same flap clears a pipe at 60fps and clips it at 30. Over three seconds of identical taps, that version's path at 240 steps a second and at 30 differ by 4.88 world units — a fifth of a gap.
The closed form for constant acceleration fixes it:
position += velocity * dt + gravity * dt * dt / 2;
But only while the acceleration really is constant, and a terminal speed means it is not. Applying the closed form and then clamping the velocity at the end of the step leaves a smaller version of the same fault — measured here at 1.5 × 10⁻² units, small enough to look like nothing and large enough to move a bird a fiftieth of a gap over three seconds.
travel() therefore splits the step at the instant the bird reaches terminal
speed and solves each piece exactly.
| integrator | spread between 240 and 30 steps a second, over 3s |
|---|---|
| split at the cap (shipped) | 4.4 × 10⁻¹³ |
| closed form, clamped at the step boundary | 1.5 × 10⁻² |
| naive: speed up, then move | 4.9 |
The tolerance is 10⁻⁹ world units — a thousand times coarser than the double-precision rounding that has to accumulate over a few thousand additions, and ten orders of magnitude finer than the bird. Nothing a step size does to a correct integrator can hide under it, and nothing an incorrect one does can hide behind it.
The arithmetic was not the only part that depended on the step size. So did the controls.
A cooldown counted down by dt lands on 2.5 × 10⁻¹⁷ rather than zero, and which
side of zero it lands on depends on how many steps the same elapsed time was cut
into. Gate the next flap on rest <= 0 and it slips to the following step —
which is 33ms at 30fps and 4ms at 240:
| rate | next flap allowed at |
|---|---|
| 30 | 0.1333s |
| 60 | 0.1167s |
| 120 | 0.1083s |
| 240 | 0.1042s |
A player mashing the button gets a different climb rate on every machine, and the course generator — built on one flap per cooldown — is quietly wrong on all but one of them. The gate allows a rounding-sized residual instead, and all four rates then agree on 0.1000s exactly.
This one survived the first version of the test suite. The suite checked the
idea by reimplementing the condition, rather than checking the shipped flap(),
so a mutant that took the tolerance back out walked through all 107 assertions.
A step is not a point sample. span() gives the exact highest and lowest the
bird's box reaches during a step — the path is convex, because the vertical
speed never decreases, so its lowest point is the top of the arc and its highest
is at one end or the other. The pipe test then asks only about the window in
which the box actually overlaps the column, which is arithmetic, because the bird
only ever moves forward at one speed.
The result is correct at any step size, and it is checked against brute force: over 40,000 random crossings at 60fps, 31,858 of them fatal, a fine sampling of the same step finds nothing the sweep misses, and the two agree on 100.00% of cases.
Two controls show what the sweep is worth. The first is the one everybody writes first — a point sample at the end of the step — and it is hopeless: of those 31,858 contacts it registers 23. The bird is a box 3.6 units across and the pipe is 4 wide, so asking whether the middle of the bird is inside the column throws away almost every graze there is.
The second keeps the box and drops only the sweep, and it is the interesting one, because the honest answer is not the dramatic one. It is nearly right at 60fps:
| step | contacts missed, of 8000 |
|---|---|
| 1/240 | 3 |
| 1/60 | 16 |
| 1/30 | 37 |
| 0.10s | 95 |
| 0.25s | 265 |
| 0.50s | 3677 |
It never reaches zero — the box is only inside the column for part of the step, and the end of the step is not in general inside that part — but at 60fps it is sixteen crossings in eight thousand. What the sweep really buys is that the number does not depend on the frame at all, and a frame will stretch: a restored tab, a slow phone, a breakpoint. The shared engine clamps a frame at 0.25s today, and the rules must not be built on that still being true.
The same sweep guards the ceiling and the floor. Of 30,000 random steps, 3,923 take the bird out of the world; the swept check misses none of them, and a check at the end of the step lets 522 fly on.
The reach is worked out three separate ways in the test: a closed form written out from scratch, a simulation at a step size nothing else uses, and a plain Euler integration that shares no algebra with anything shipped. All three agree before a single course is generated.
Then a pilot is asked to actually fly. Its whole policy is flap whenever the bird is at or below the middle of the gap it is heading for — no planning, no knowledge of the reach model, which is what makes it worth something as a witness.
1800 courses of eighty pipes, at 30, 60 and 144 steps a second
constrained generator 0 deaths
unconstrained generator 400 of 400 dead, after 4.2 pipes on average
$ node test-flight.js
113 passed, 0 failed
Twelve mutants. Eleven broke something real; the twelfth changed nothing on purpose.
| mutation | result |
|---|---|
travel is naive: speed up, then move at the new speed |
caught (20 assertions) |
travel uses the closed form but clamps at the step boundary |
caught (16) |
| gaps are picked independently, with no reach constraint | caught (3) |
| the reach is measured over two spacings instead of one | caught (8) |
| collision treats the bird as a point | caught (5) |
| collision samples the end of the step instead of sweeping it | caught (6) |
| the world edges are checked at the end of the step only | caught (2) |
span forgets the top of the arc |
caught (4) |
| a pipe already counted is counted again | caught (20) |
| the flap cooldown is gated on exactly zero | caught (3) |
scoreFor is a stage out |
caught (6) |
advance wrapped, behaviour unchanged |
passes, as it should |
Two of these needed the suite strengthening before they were caught. The cooldown mutant is described above. The world-edge mutant was caught by exactly one constructed case, which is one more than nothing and fewer than it should be; it now faces the same brute-force property the pipe sweep does.
Every eight pipes the gap tightens by one unit and a pipe becomes worth five more:
| pipes | gap | each worth |
|---|---|---|
| 1–8 | 24 | 10 |
| 9–16 | 23 | 15 |
| 17–24 | 22 | 20 |
| 25–32 | 21 | 25 |
| 33+ | 20 | 30 |
scoreFor states that as a base times the count plus one term per tightening,
rather than as a walk over the pipes — which is what lets the test check it
against a walk over the pipes. A hundred pipes is 2,600.
A pipe counts once the bird's centre is past its right edge, and the flag that records it is why "once" means once. Where the run stopped decides which pipes count, and that is always a lower bound: the near edge of the pipe it struck, or the start of the step it flew out of the world in. Nothing is credited for ground the bird did not cover.
Tap anywhere, or press Space. That is the entire control scheme.
The bird waits for your first flap, so a run does not start falling the moment
the menu closes. Escape or P pauses; R starts over.
One thing worth knowing, because it is a fact about the numbers rather than a bug: at terminal speed the bird drops 11.65 units while crossing a pipe's column, and the gap is between 20 and 24. You cannot plummet through a pipe. Arrive slow.
Advertisement