TinyGames · how it works
A pass-the-phone game for two to six players on one screen.
On your turn you press a finger down anywhere on the field and drag to aim. A ball drops where you pressed and sets off in the direction you dragged. It never stops, never slows and never bounces — when it runs off an edge it comes straight back in on the opposite side, still on the same line. Survive a second and a half of flight and the phone goes to the next player, who adds another ball to a field that is now busier.
The round ends the moment two balls touch. Whoever's ball caused it loses; everyone else walks away. Get all twenty-four onto the field without a contact and nobody loses at all.
By default the field never stops — the next player aims into moving traffic. L freezes it between turns instead.
Open index.html. No build step, no dependencies, no images.
nearmiss/
index.html markup
style.css the frame around the canvas
js/engine.js copied unchanged from Overdrive - the fifth game on it
js/torus.js the wrapping geometry and the contact solver
- no pixels, no engine, no DOM
js/nearmiss.js the playable layer: turns, aiming, drawing
test-torus.js node test-torus.js
bump-version.py stamps version.json, the meta tag and the cache busters
A ball that leaves the right edge and returns on the left is not a rectangle with a trick in it. It is a torus — a doughnut — and that single change breaks the most basic question the game has to answer.
"How far apart are these two balls?" now has nine answers, and only one of them is right. Two balls at x=5 and x=955 on a 960-wide field are ten pixels apart, not 950. Subtract their coordinates and you get the wrong answer, draw the conclusion, and the game happily lets two balls sail through each other while insisting they were nowhere near.
dx -= w * Math.round(dx / w); // pick the nearest of the nine images
Everything awkward lives in torus.js, and none of it knows what a canvas is.
The obvious implementation is to ask, once a frame, whether any two balls overlap. It is also wrong, and wrong in a way that only shows up sometimes.
Two balls closing head-on at 3,000 px/s shut a fifty-pixel gap in a single 60 Hz frame — more than twice their combined size. Sampled at frame boundaries they are apart, then apart again, having passed clean through each other in between. In a game that ends on contact, that is not a graphical glitch. It is the wrong answer to the only question being asked.
So contact is never tested, it is solved. In the relative frame one ball is standing still
and the other travels in a straight line, and contact is |d + ut| = sep — an ordinary
quadratic. The wrapping is handled by solving it once per image of the field within reach,
which is not clever but is provably complete:
$ node test-torus.js
34 passed, 0 failed
The headline test fires 2,500 random pairs at a slow, deliberately stupid stepper that
walks the motion in 40,000 slices and shares no code with the solver. Nothing missed,
nothing invented, every time agreed. A second test fires 400 pairs closing at 3,000 px/s
and confirms all 400 are caught inside a single frame. A third steps the balls right up to
the predicted instant and checks they really are touching — every round in play ends with
the closest pair exactly 2r apart, to four decimal places.
On a torus, the relative motion of two balls is a straight line drawn on a doughnut. If its slope is irrational the line never closes on itself and passes arbitrarily close to every point on the surface — so it must eventually enter the small disc that means contact. A finger cannot pick a rational slope on purpose, which is why a round reliably ends.
It is not a certainty, and the test says so: it asserts that almost every random pair meets within 400 seconds, not that all of them do. The exceptions are real, and one of them turns out to be the whole game.
The first settings — 13-pixel balls at 300 px/s with a three-second window — ended a four-player round after four balls. One turn each. The round was over before the game started.
The reason is that the hazard grows with the square of the ball count: k balls make
k(k-1)/2 pairs, and every pair is independently sweeping a slice of the field. A sweep
across a 3.5× range of speed, radius and window length moved the median from four only as
far as seven. Numbers were not going to fix it.
What fixed it was that skill has enormous leverage here, which the first settings were hiding. At the settings that shipped — 10-pixel balls, 170 px/s, a 1.8-second window — simulated players who differ only in how much thought they give the placement get:
| how the ball is placed | balls onto the field |
|---|---|
| dropped at random | 5 |
| glancing at the traffic | 16 |
| reading it properly | 19–24 |
That gap is the game. Every number above is a median of 25 simulated four-player rounds.
Every ball moves at exactly the same speed. That is a rule, not an oversight: two balls on the same heading have zero velocity relative to each other, so they hold their separation forever and can never meet. Aiming parallel to traffic is genuinely, provably safe, and working that out from watching the screen is the best thing a player can discover here.
It is also strong enough to break the game. A table that all copies one heading is never in any danger — thirty simulated rounds of it ran to a thousand seconds each without a single contact, which is how it was found: the test harness hung.
The tempting fixes are all bad. Jittering the launch angle makes aiming feel dishonest. Giving each player a different speed only slows the exploit down: two headings a single degree apart close at about 3 px/s, which is safe for the better part of an hour. Both sabotage the geometry to patch one hole.
So the board holds twenty-four balls and no more. Fill it and the round ends with everyone still standing. The exploit stops being a way to play forever and becomes the way to win, which is a much better thing for it to be — and it still has to be executed:
| aim copied to within | rounds that filled the board |
|---|---|
| exactly 0° | 25 / 25 |
| 1° | 13 / 25 |
| 3° | 3 / 25 |
| 8° | 0 / 25 |
| 20° | 0 / 25 |
A finger dragging sixty pixels is good for perhaps two to five degrees. The insight is worth a great deal and is nowhere near a solution.
Config.LIVE_AIM decides whether the world stops between turns, and it changes the game
more than it looks like it should.
Live (the default) means nothing ever stops. You line up a shot into moving traffic, and taking your time is itself a risk — a contact can happen while you are still deciding, and it will still be somebody's fault. The spot you pressed on may not be clear by the time you let go, so the drop point's legality is re-checked every frame rather than frozen at the moment your finger landed; otherwise the ring would go on telling you a spot was safe after it stopped being one.
Frozen holds the field still between turns, so a drop can be planned exactly. Fairer, calmer, and a good deal less alarming.
The cost of dithering is measurable. Same competent placer, same four players, varying only how long they take to decide:
| seconds spent deciding | frozen field | live field |
|---|---|---|
| 0 | 15 | 15 |
| 2 | 16 | 10 |
| 5 | 16 | 9 |
On a frozen field, thinking is free. On a live one it costs about a third of the round, which is the entire point of the setting. L switches between them.
Three things exist purely so the game is playable rather than a guess:
The edges are dashed, not solid. A player who reads them as walls expects a bounce, aims accordingly, and is wrong about where their own ball goes.
A ball near an edge is drawn again on the far side, so it is visibly half in and half out of both edges at once and the wrapping never needs explaining.
While you are aiming, every ball in play draws its whole line and yours draws a dashed preview through as many wraps as it takes. This is deliberately geometry only — it says where a line goes, never when a ball is there. Two crossing lines are not a collision unless both balls arrive together, and judging that is the skill the preview cannot do for you.
Colour is never the only difference. Six players on one small screen is exactly where hue alone fails, so every ball also carries its own shape — dot, ring, triangle, square, cross, star.
The two balls belong to two players, and blaming whoever happens to be holding the phone is the wrong answer: a contact can perfectly well be between two balls dropped several turns ago. The later of the two balls is the one that was aimed into an occupied field, so its owner is out. Most of the time that is the current player, which is what makes it read as fair; when it is not, the message names both balls so it still does.
| Press and drag | place a ball and aim it |
| Release | drop it |
| A short tap | does nothing — that is not an aim |
| 2–6 | pick the number of players |
| L | live field / frozen between turns |
| R | rematch, same line-up |
| N | new line-up |
Dropping a ball on top of one already in play is refused, and the aim ring turns red before you let go. That is the one mistake worth taking off the table; every other way of losing is yours.
At a full board of twenty-four balls the solver runs 276 pairs through nine field images every frame — 0.014 ms, about 1,200× inside the 60 Hz budget. Drawing costs 0.35 ms. Three hundred simulated rounds across every player count produced no non-finite position, no ball off the field, and no stalled frame.
The same version.json convention as the other four games and the Angular apps, with the
same update check and the same bump-version.py.
Advertisement