TinyGames · how it works

Chute

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 rules, in full

The well is five columns by eight rows. A drop is:

  1. If the chosen column has no empty cell, the drop is refused and the run is over. Nothing else changes.
  2. Otherwise the block lands in the lowest empty cell of that column.
  3. Then, over and over until nothing changes: - everything falls as far down its own column as it can; - the well is scanned from the bottom row upward, left to right within a row, and the first pair found joins — the block below before the block to the right; - a vertical pair leaves its doubled block in the lower cell, a sideways pair in the left one.
  4. The block that was previewed becomes the block in hand, and a new one is previewed. It is a two, or a four one time in three.

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.


The order is the game

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.

Two of the rules cannot be seen at all

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.


Three traps in the resolve

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.


The score is checkable

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.


What the tests actually assert

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:

The mutation table

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

Still to do