TinyGames · how it works

Stackup

A block swings past; drop it. Whatever hangs over the edge is sliced off, and the next one only gets what is left.

Draft. It is built, compiled and tested like every other game, and it is not on the hub: "draft": true in build/games.json keeps it off the cards, out of the sitemap and without a readme page. Take the flag off when it is worth playing.

Open index.html. No build step, no dependencies, no images — the tower is drawn.

stackup/
  index.html      markup, and the restart button
  style.css       the frame around the canvas
  js/tower.js     the intersection, the swing and the arithmetic
                  - no pixels, no engine, no DOM
  js/stackup.js   the playable layer
  test-tower.js   node test-tower.js

The whole game is one piece of arithmetic

Intersecting two intervals. Get it exactly right and the game is right; get it slightly wrong and the tower drifts, or grows, or survives a clean miss.

new block  =  [ max(left, left) , min(right, right) ]

That is the entire rule, and every property below falls straight out of it. The new block cannot be wider than the one underneath, and cannot poke out past either of its edges, because the larger left and the smaller right are the only form that has no way to.

Three things go wrong with this, and all three look fine for the first six drops.

The tower drifts

Compute the new block from the dropped one's width and a shifted centre — which is how most of these games are written — and it walks sideways out of the tower a fraction at a time. The intersection cannot do that, whatever it is handed.

Touching is not landing

Two blocks whose edges exactly meet share no width at all. If that counts as a hit, the game keeps a block of nothing, and then stacks on it forever: every later drop intersects that same nothing and dutifully keeps it. The run has to end at the boundary, not one step past it, so the test lands on it exactly, a billionth inside it, and a billionth outside it.

A perfect drop must lose nothing

Not "nothing to within a rounding error". The error is paid once per block, always in the same direction, and fifty flawless drops is a visibly narrower tower. This is the bug these games actually ship with, and it never announces itself — the game congratulates the player each time it charges them.

Two design decisions come from that one sentence.

A block is a pair of edges, not a centre and a width. max(l, l) and min(r, r) hand back one of their arguments untouched rather than computing anything, so an aligned drop is exact by construction. A centre and a half-width cannot promise it: c - w/2 and back again is two roundings per drop, and they do not cancel.

Landing inside the tolerance moves the block, rather than only forgiving it. Announcing "perfect" while still slicing off the fraction of a unit is precisely the shrinking tower. Snapping makes the announcement true.

The tolerance is a share of the block, capped — never a fixed number of units. A fixed two units on a block two units wide forgives everything that is not an outright miss, snaps it back to full width, and the tower becomes immortal.


What the tests actually assert

$ node test-tower.js
116 passed, 0 failed

Property-based throughout: 2,000 runs, 20,000 intersections, 20,000 samples of the swing. The interesting ones:

property how it is checked
the block never grows every block against the one below it, every run
it never pokes out both edges, same sweep
the slice is the overhang sliced === dropped − kept, exactly, and never negative
touching is a miss the boundary itself, and a billionth either side of it
fifty perfect drops cost nothing Object.is against the starting width

Three independent references for the intersection, because one that shares arithmetic with the thing it is checking is not a check:

The second one was wrong when it was first written. (wa + wb) / 2 − |ca − cb| is the textbook overlap and it is only correct when neither interval swallows the other; a narrow block sitting inside a wide one reported ninety-five units where the answer was half a unit. 861 pairs out of 20,000, every one of them nested. The rule held; the reference needed a min.


The one worth reading

The width after fifty perfect drops is exactly the starting width.

Object.is, not Math.abs(a - b) < 0.001. A test written with a tolerance passes the bug it exists to catch.

It is asserted three ways: on the arithmetic alone (intersecting a block with itself fifty times, on coordinates chosen to be unhelpful — a third, and a width that is not representable), through fifty exactly-aligned drops, and through fifty drops that are merely close — four tenths of a unit off, inside the snap tolerance, alternating sides.

The negative control rebuilds the mistake: an implementation that reports "perfect" and slices the four tenths off anyway. It calls all fifty drops perfect and has taken twenty units off the tower by the end. The real one takes nothing, and the difference is what the test is looking at.

Four negative controls in all — keeping the whole block, treating a touch as a hit, praising a drop without moving it, and handing width back for a streak (the way Ketchapp's Stack does, which is why the monotone property here is absolute rather than nearly so).


The swing

Deterministic and frame-rate independent, because it is a pure function of elapsed time — a triangle wave — rather than a position nudged by dt each frame and turned round when it passes the end. The nudged version overshoots by however far the last frame carried it, so the turn happens further out on a slow machine than on a fast one, and the swing is quietly a different width for every player.

The test cuts the same three and three quarter seconds into steps of a 1024th, a 256th, a 64th and a 16th, and asserts all four agree exactly — the steps are dyadic so the sums are exact in binary and any difference is the game's doing rather than the adding up's. A ragged, stuttering frame rate lands in the same place.

It also asserts the block never moves further than its own speed times the interval, over 20,000 short intervals. A wave that wrapped instead of turning round stays inside its bounds and passes everything else; this is what notices.

The swing speeds up as the tower grows, and the clock restarts when a block lands — because the speed changes at that moment, and a shared clock would multiply the new speed by the old elapsed time and throw the next block to an unrelated part of its arc between one frame and the next.


What the tests are worth

Eleven mutants, made by editing src/stackup/tower.ts, rebuilding, and running the suite against the shipped minified JavaScript. Ten broke something real; the eleventh changed nothing on purpose.

mutation result
overlap takes the hull, not the intersection caught (36 assertions)
touching counts as landing caught (12)
a miss does not end the run caught (6)
the perfect tolerance is fixed, not proportional caught (5)
perfect is announced but not snapped caught (3)
the dropped block is kept whole, nothing sliced caught (3)
the swing wraps instead of turning round caught (3)
the swing never speeds up caught (3)
the swing clock is not reset on a drop caught (2)
the perfect bonus is not paid caught (2)
overlap wrapped, behaviour unchanged passes, as it should

Two of these survived the first version of the suite. The clock-reset mutant survived because the big sweep never advanced the clock at all — it dropped at t = 0 every time, so a clock that was never reset looked exactly like one that was. It now has a test that runs the clock, drops, and checks where the next block starts.

The miss-does-not-end-the-run mutant did something more useful than survive: it crashed the suite. A run that never ends never leaves the loop, and the harness grew an array until Node refused. A suite that dies halfway through stops testing everything after that point, so the loop is now bounded by the drops as well as by the blocks placed — a fault in the test, found by a mutant, which is what mutants are for.


Playing it

One button. Tap anywhere, or Space. Escape or P pauses, R starts over.

The block always enters from the far edge of its reach, which is well clear of a full-width tower, so there is no such thing as a free first drop. Land inside the tolerance and the block is snapped square and the streak carries on; land outside it and you keep only what overlapped. Miss entirely — including the exact touch — and that is the run.