TinyGames · how it works
Drop numbered blocks into a five-wide well. Equal blocks that end up touching join into one worth double, and that new block can touch something equal too, so one drop can pay several times over. You are always shown the next block as well as the one in your hand.
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 well is drawn.
chute/
index.html markup, and the restart button
style.css the frame around the canvas
js/well.js the well, the fall, the joining and the score
- no pixels, no engine, no DOM, no Math.random
js/chute.js the playable layer
test-well.js node test-well.js
The well is five columns by eight rows. A drop is:
A merge pays the block it makes: two fours pay eight. That is 2048's rule, and it is not an arbitrary choice — see the score is checkable below.
The run ends on a refused drop and on nothing else. There is no height limit, no timer and no losing move other than running a column out of room.
Joining is not commutative here. Which pair goes first decides what ends up next to what, and therefore what goes second. Four different mistakes each produce a game that plays perfectly well and is not this one:
| the mistake | what it does to 2 2 4 lying along the floor |
|---|---|
| scan downwards | a different well from the same score |
| scan right to left | the right-hand pair joins, not the left |
| take the sideways pair before the one below | the cascade stops one merge early |
| keep the doubled block on the right | _ 8 instead of 4 _ 4 — three times the score |
All four are rebuilt in test-well.js and watched disagreeing with the
independent implementation, because a comparison that could not fail would pass
just as quietly as one that could.
This is worth knowing before somebody simplifies either of them.
A vertical pair's doubled block goes in the lower cell — and putting it in the upper one is exactly the same game, because the fall puts it back before anything looks at the well again. The suite demonstrates that rather than claiming it, and the mutation table carries the change as a control that must pass.
A block lands in the lowest empty cell — and landing it in the highest, with gravity to catch it, leaves an identical well. The only thing that can tell them apart is the cell the drop reports landing in, which is what the playable layer draws its marker on. So a player would be shown the wrong cell while the game behaved correctly, and exactly one assertion stands between that and shipping.
The other half of the same sentence — a sideways pair keeps the left cell — is worth a whole score, because nothing moves a block sideways afterwards.
Two empty cells are equal. An empty cell holds 0, and 0 === 0. A scan that
forgets to skip empties finds a pair in the air above the blocks, makes a block
worth nothing, empties a cell that was already empty, and is handed the same well
to scan again. That is not a crash — it is a tab that stops responding, on a drop
that looked ordinary, with nothing in the console. settle() carries a bound
that turns it into a loud failure instead, and the bound is the termination
argument written down: every merge destroys exactly one block, so a drop cannot
cascade more than cols * rows - 1 times and the loop cannot reach its last
iteration.
Gravity has to run inside the loop. A merge empties a cell in the middle of a column and leaves everything above it hanging. Fall once at the start instead and the rest of the cascade is searched for on a well full of holes: two blocks that should have been brought together never touch, and the score is quietly short. It reads as bad luck, which is why it survives play-testing.
The preview must be honest. next is the entire reason this game exists. Draw
a fresh block into the hand and leave next where it was, and the game still
plays, still scores, and shows the player a block that never arrives — with
nothing on screen to say so. drop() promotes first and draws second, and the
suite follows the previewed value through to the drop several thousand times.
Because a merge pays what it makes, the score can be recomputed from the well alone without looking at a single merge.
Give every block a potential of v * (log2(v) - 1) — a 2 is worth nothing, a 4
is worth 4, an 8 is worth 16. Joining two blocks of value v raises the well's
total potential by exactly 2v, which is exactly what that merge pays. Falling
changes no potential, and an arriving two adds none. So at every moment of every
run:
score == potential of the well - 4 for every four ever dropped
The suite plays runs out to the full column and checks that after every drop. It is a real cross-check rather than a restatement of the scoring function, because it never looks at a merge.
node test-well.js — 125 assertions, run against the shipped, minified
JavaScript rather than the TypeScript source, which is how a minification fault
gets caught.
The centrepiece is a second implementation in a different shape. The shipped
rules keep a flat array of cells with row 0 at the top and close the holes a
merge leaves with a gravity pass. The one in the test keeps each column as a list
packed from the floor up, where a landing block is a push, a full column is a
list already rows long, and a merge is a splice that makes everything above
it fall by itself. There is no gravity function to forget to call and no empty
cell to mistake for a block, because neither exists in that representation.
Whole runs are then played on both and compared after every drop: the cells, the score, the blocks each merge made, the cell the block landed in, what is in hand and what is previewed. Three hundred and seventy runs across six well sizes, including a two-wide one and a twelve-row one, because nothing in the rules may be wired to five by eight.
Alongside that:
v + v into 2v. Note that this does not catch a wrong resolve, which
is why the comparison above exists;Sixteen deliberately broken copies of the shipped file, each with one function replaced, put through the whole suite. Every one must be caught. Two controls must pass: a wrapper that changes nothing, and the vertical upper-cell change that is a genuine no-op.
node test-well.js
Public/thumbs/"draft": true off the entry in build/games.jsonAdvertisement