TinyGames · how it works

Flutter

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

Everybody has written this game, and it has two traps in it

The first trap: the course has to be flyable

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 second trap: frame-rate independence

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.

And a third thing, which is the same trap wearing different clothes

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.


Collisions are swept, not sampled

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 tests do not take the code's word for any of it

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

What the tests are worth

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.


Scoring, and the ramp

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.


Playing it

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.