TinyGames · how it works
Flick soccer, on a table. Drag back from one of your three counters, let go, and hope it puts the ball past a keeper who is moving.
Open index.html. No build step, no dependencies, no images — the pitch is drawn.
flickon/
index.html markup, and the restart button
style.css the frame around the canvas
js/pitch.js the table, and then the football on top of it
- no pixels, no engine, no DOM
js/flickon.js the playable layer
test-pitch.js node test-pitch.js
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 has
been played enough to earn a card.
Flick sports are a category — shove-ha'penny, crokinole, table football, carrom — and they mostly share one shape: put a piece somewhere. Soccer is the one that gives the shot a target and an opponent at the same time. There is something to hit (the ball), somewhere for it to go (a mouth 68 units wide), and something in the way that is not the geometry (a keeper on a rod). A game about leaving a counter in a scoring zone has only the first of those, and it is a much quieter game.
Flick basketball is next and will run on this same table with a hoop instead of a goal, which is why the rules file is split the way it is.
pitch.ts is in two clearly separated parts, and the seam is the point.
Part one is a table of discs. Position, velocity, cushions, friction, collision between equal masses, deflection off a fixed round obstacle, and "has everything stopped". It does not know what a goal is, what a keeper is, whose turn it is, or that anyone is keeping score. It is written to be lifted into the shared tier unchanged.
Part two is the football. The pitch, the goal mouth, the posts, the keeper, what counts as a goal, and how a match of five rounds is run. It reaches into part one only through the functions part one exposes.
The tests are split the same way. Everything down to PART TWO in
test-pitch.js is about discs on a rectangle and never mentions a goal —
settling, escaping, tunnelling, overlap, conservation, the clock. If the
physics were only ever exercised through a game of football, promoting it would
mean re-proving it.
Every one of them is a fault that shows up once in a thousand shots, which is to say never while anyone is playing and always once it is live.
Multiplying the speed by a fraction each frame never reaches zero. It reaches 1e-129 — measured, in the suite — and keeps creeping, and the player waits for a turn that will not end.
Friction here subtracts a fixed amount of speed per second and stops dead
below a threshold. That makes the settling time arithmetic: a disc at speed v
stops within (v - STOP_BELOW) / FRICTION seconds. And because an elastic
collision between equal masses cannot leave either disc faster than the faster
one that met, the fastest speed on the table never rises — so that bound
survives any number of collisions, cushions and keepers.
Four thousand flicks, every power from a fumble to well past full and every
angle, are run to a standstill and checked against that number, worked out in
the test file from FRICTION and STOP_BELOW alone rather than borrowed from
the code being tested. Every one stops, none exceeds the bound, and the longest
rolls for 241 steps — just over two seconds.
A hard flick covers thirty or forty units between two frames. A counter is ten across. Test a collision only at the ends of a frame and it is a collision that never happened.
So the step is sliced: no disc travels more than 2.2 units between two collision tests, and the number of slices is read off the fastest disc at the start of the step. There is deliberately no clamp anywhere putting an escaped disc back on the table — the guarantee comes from the slicing, and a clamp would only hide the day the slicing broke.
960,000 disc-steps at five step sizes from 1/240 to 1/8, with flicks at twice full power. Worst excursion: zero.
Two ways of writing it wrong, which fail at two different thresholds. Being precise about which is the whole value of the exercise:
| the naive version | at 60fps | at a half-second step |
|---|---|---|
| turn the velocity, leave the disc where it is | off the table 937 times, by up to 19.4 units | — |
| slice nothing, but mirror the disc back in | never escapes | off the table 412 times, by up to 511 units |
| the shipped step | never | never |
The middle row is the trap. Dropping the slicing does not throw anything off this table at a normal frame rate — mirroring the disc back is a strong enough correction to survive a forty-unit overshoot — so a test that only watched the cushions would call that version fine. It is not fine: it walks counters straight through each other.
Fire a counter at the ball with a known offset. The independent calculation is the closest approach of the striker's straight-line path to the ball's centre: if that is under the two radii, they must meet, whatever the integrator believes. So the ball must have moved.
Six hundred such shots at each of three step sizes. The shipped step strikes the ball every single time. One step per frame goes clean through it 48 times at 1/30 — eight per cent, intermittent, and exactly the kind of thing a player reports as "it just went through".
The same test is run against a fixed obstacle, because a ball that passes through the keeper is the same bug wearing gloves.
There is one reflection in the file and everything comes through it, so the geometry is written once. Swept across 356 incidences at four speeds, at restitution 1: the component along the surface is bit-for-bit unchanged, the speed is bit-for-bit unchanged, and the angle out equals the angle in. Off normals that are not axis-aligned the arithmetic is no longer exact and the suite says so: 7.7e-16 relative, against a stated 1e-12.
At the cushion's actual 0.72 the same two facts hold in the form that matters: the sideways component is untouched, and the other one is scaled by exactly 0.72.
The physics runs on a clock of its own in fixed 1/120s steps. A frame does not drive the integrator; it only says how many steps are now owed.
The same forty shots played at 240, 120, 60 and 30 frames per second settle in exactly the same places — the measured disagreement is 0, not "under a tolerance". Mid-flight, at four matched moments, also 0.
The stated tolerance is 1e-9 world units on a table 320 across, and it is not a judgement about acceptable drift. The physics either runs the same sequence of micro-steps or it does not, and the suite measures what one step either way costs: 6.68 units of counter position. There is nothing in between those two outcomes, so any tolerance between 1e-9 and 1 would say the same thing. 1e-9 is roughly seven billion times smaller than the smallest way this could actually fail.
The one piece of arithmetic that earns its keep is an epsilon. Adding 1/30 sixty times and 1/240 four hundred and eighty times do not produce the same double, and without the epsilon the four frame rates disagree about how many steps are owed at every whole second tried. That is measured too, not assumed.
A goal is the crossing, not the resting place. A ball struck flat out at the middle of the mouth goes in, hits the back cushion and comes back out — and it finishes 36 units in front of the goal.
It is a goal. The suite runs that shot at four step sizes and it is given every time, because the line is judged between one slice and the next and the y is interpolated at the moment of crossing. A rule that asked where the ball stopped would have waved play on, rarely enough that nobody would ever reproduce it.
The net is inside the cushions rather than a mouth cut into them, for the same reason there is no clamp: the physics is allowed to assume exactly one shape, and a hole in it would be a special case in the only thing that must not have any.
He is on a rod, which is what a table football keeper is. His position is a pure triangle wave of the round's clock, so he cannot drift, cannot be bundled into his own net by a stray counter, and stands in exactly the same place on every machine.
He also takes pace off the ball and never puts any on. A keeper who swatted the ball away would be a source of speed that did not come from a flick, and the settling bound above rests on there being no such thing.
Twelve balls rolled straight at him from twelve different points in his swing: twelve saves. One into the far corner: a goal. Both matter — a keeper who saves everything is a goal that is closed.
Ten mutants. Nine broke something real; the tenth changed nothing on purpose.
Each one is a rebuilt step with a single piece replaced, and the rebuild is checked against the shipped step first — 96,000 discs compared bit for bit — because a rebuild that had drifted would make every result below meaningless.
| mutation | what it broke | result |
|---|---|---|
rebound turns the disc round but leaves it where it is |
nothing leaves the table | caught (79) |
| the step is never sliced | nothing passes through anything | caught (33) |
| friction scales the speed instead of subtracting | everything comes to rest | caught (150) |
| a collision is plastic however elastic it was asked to be | momentum and energy | caught (1504) |
| a collision shoves the pair apart with speed, not distance | momentum and energy | caught (3000) |
| a rebound turns the whole velocity round | angle out equals angle in | caught (89) |
| the clock counts steps without its epsilon | every frame rate owes the same steps | caught (5) |
| a goal is judged by where the ball stopped | a goal is the crossing | caught (1) |
| the keeper runs on a sawtooth rather than a triangle | the keeper stays on his rod | caught (17) |
rebound wrapped, behaviour unchanged |
— | passes, as it should |
Alongside: 20,000 equal-mass collisions conserving momentum to 7.7e-15 and kinetic energy to 2.2e-15 relative; 600,000 pairs measured after a step and none overlapping by more than one slice of travel; and 120 whole matches played out by a bot that always aims at the ball, scoring 219 goals in 600 rounds with nothing ever leaving the table for a frame.
The energy test is there because momentum alone does not catch enough. A collision that simply hands the whole velocity over conserves momentum and energy and is still wrong — what gives it away is the direction, so the suite checks a glancing blow sends the ball off the line rather than along it.
$ node test-pitch.js
180 passed, 0 failed
Touch. Put a finger down near one of your three counters — the nearest one is the one you get — then drag backwards and let go. It is a catapult, not a joystick: drag left and the counter goes right. The arrow shows where it will go and how far you dragged is how hard.
The drag is measured from where the finger went down, not from the counter, so a stray tap has no pull in it and fires nothing. A pull too small to matter is refused rather than charged to you, and a finger that leaves the canvas abandons the shot instead of taking it.
Keyboard. Q and E pick a counter, the arrows or WASD turn the aim and
set the power, space flicks. R starts the match again, Escape or P
pauses.
Five rounds, four flicks each. A goal is worth 500 and every flick you did not need is worth 250, so the difference between a good match and a lucky one is how early the ball goes in. Only the ball scores — a counter over the line is just a counter over the line.
"draft": true off the entry in build/games.jsonAdvertisement