TinyGames · how it works
Tapper. Four bars, everyone on all four of them walking towards you, and one of you. Slide a drink down to push a customer back; be standing there to catch the empty on its way home.
Open index.html. No build step, no dependencies, no images — the bar is drawn.
barkeep/
index.html markup
style.css the frame around the canvas
js/bar.js the bars, what is on them and the arithmetic
- no pixels, no engine, no DOM
js/barkeep.js the playable layer
test-bar.js node test-bar.js
Did these two pass each other?
A drink runs one way at 64 units a second, a customer walks the other at 7.5, and a frame is a sixtieth of a second. They close about 1.2 units per frame — smaller than a customer, so far so harmless. Drop a frame, or let the drinks get quicker with the rounds, and the step grows until the drink is past the customer without ever having been level with them. It then runs off the end of the bar and costs a life the player did nothing to deserve.
So nothing here asks where things are. It asks where they went:
crossed(a0, a1, b0, b1, reach)
Two things moving in straight lines over one step, so the gap between them is a
straight line too — and a straight line is within reach of zero somewhere in [0, 1]
exactly when it is close enough at one end or changes sign in between. Three
comparisons. No sampling, no smallest-safe-timestep to choose, and no speed at which
it quietly stops working.
The whole bar is then required to play out the same at every step size: the same opening, run at 1/240, 1/120, 1/60, 1/30, 1/15 and 1/8 of a second, must reach the same conclusion. An endpoint check fails that the moment the step grows.
The independent reference walks the step in ten thousand slices and looks at the gap
at each one. Over 20,000 random steps it disagreed with crossed twice — and both
times crossed was right.
The window in which two things are within reach lasts 2·reach / closing-speed of a
step. In those two cases it was 4.6e-5 and 1.2e-5 of the step, against a sampling grid
of 1e-4. The walk stepped straight over a touch that really happened.
Rather than hide that, the sweep is now split. Where the walk can resolve the answer
the two must agree exactly — and they do, across 19,000-odd steps. Where it cannot,
crossed is allowed to find touches the walk missed, and must never do the reverse.
It finds them, which is the reason for not sampling in the first place.
$ node test-bar.js
57 passed, 0 failed
Six mutants. Five broke something real; the sixth changed nothing on purpose.
| mutation | result |
|---|---|
| collision by endpoints only | caught (7 assertions) |
| collision ignores the sign change | caught (6) |
| no empty is ever sent back | caught (5) |
| a drink serves the far customer | caught (2) |
| an empty is always caught | caught (1) |
crossed wrapped, behaviour unchanged |
passes, as it should |
The nearest-customer mutant survived the first version. The test had two customers forty units apart, so no single step ever swept both — and picking the nearest and picking the furthest are the same thing when there is only one to pick. It now uses one half-second step in which the drink passes all three at once.
A drink that reaches the end of a bar unclaimed costs a life. That is the original's rule and it is the reason you have to aim.
It is only a fair rule if there is usually somebody to aim at. The first version had a customer arriving every 2.6 seconds across four bars — a bar was empty most of the time, and pouring into nothing stopped being a mistake and became the ordinary case. Measured, not guessed: three lives went in eleven seconds, all three to spilled drinks and none to anything the player could have seen coming.
Customers now arrive roughly every 1.5 seconds, closing on 0.55, and there are four lives. The same run then clears a round with every life intact.
Tap a bar to walk to it; the POUR button serves the bar you are on. Arrows or W/S move, Space pours.
Moving and pouring are deliberately separate. Half of this game is walking to a bar you are not serving, to be standing there when an empty arrives — a single tap that both moved you and poured would make that impossible on a phone.
You lose a mug when a customer reaches you, when an empty comes home to nobody, or when a drink runs off the far end.
Advertisement