TinyGames · how it works
An endless match-3 in the Bejeweled shape: swap two neighbours to line up three or more, watch the cascades, and keep going. There is no level, no timer and no losing condition.
Open index.html. No build step, no dependencies.
matchmania/
index.html markup
style.css the frame around the canvas
js/engine.js copied unchanged from the Overdrive example
js/board.js the rules - no pixels, no engine, no DOM
js/matchmania.js the playable layer: gems, input, HUD
test-board.js node test-board.js
assets/ Kenney Puzzle Pack II, CC0
Two reasons, and neither is "another game".
It proves the engine claim. The Overdrive example says its engine.js knows nothing
about its game and would carry a second one unchanged. That is easy to write in a README
and easy to be wrong about. js/engine.js here is the same file, copied across with no
edits, running something with no gravity, no scrolling and no player character.
It shows what a testable rule looks like. board.js is a class over an array of small
integers. It cannot draw, does not know what a canvas is, and never touches the DOM. That
is the only reason the interesting rule below could be tested before a single gem was
drawn.
The part worth studying. When no swap anywhere on the board would make a match, the board is dead. Bejeweled does not end the run at that point - it announces the problem and deals the same gems out again.
the board is dead -> "No more moves - shuffling"
-> reshuffle the same gems
-> keep playing
Board.findAnyMove() answers the question. It tries every adjacent pair, swaps, asks
whether a run appeared, and swaps back. Only right and down need testing: every
adjacent pair is the right or the down neighbour of exactly one cell, so checking all four
directions would test each pair twice.
Board.shuffle() has to satisfy two things at once - no run already on the board, and at
least one move available - so it reshuffles until both hold. If the gems on the board
genuinely cannot satisfy both, which is possible with few colours on a small grid, it
falls back to a fresh deal rather than looping forever.
A striped board looks dead:
1 2 1 2 1 2
2 1 2 1 2 1
1 2 1 2 1 2
It is not. Swap any two cells vertically and the stripe becomes a horizontal triple:
1 1 1 2 1 2 <- three in a row
2 2 2 1 2 1 <- and three more
I convinced myself that board was dead while writing the tests, and the test failed
because my reasoning was wrong, not the code. That board is now kept as a regression
guard, and the real tests do something better: they cross-check hasAnyMove() against a
second, deliberately naive implementation over thousands of random boards, and assert that
the sample really did contain both dead and live boards. Agreement between two independent
implementations is worth far more than agreement with a board someone talked themselves
into.
$ node test-board.js
25 passed, 0 failed
Dead boards essentially never occur on 8x8 with six gem types, which is why the tests use a 4x4 grid to produce them in quantity.
Six types, each a different shape as well as a different colour:
| square | blue | diamond, red |
| pentagon | green | octagon, yellow |
| triangle | pink | star, orange |
A board that differs only by hue is unreadable for a colour-blind player and harder for everyone at a glance. The shapes come from Kenney's Puzzle Pack II, which supplies the same 72 shapes in eight colours - so picking a different shape per colour costs nothing.
40 x gems cleared x cascade depth. The cascade counter resets on each new move, so a
four-deep chain from one swap is worth far more than four separate swaps. Best score is
kept in localStorage.
| Swipe | press a gem and drag toward the neighbour you want it swapped with |
| Tap, tap | select a gem, then tap a neighbour |
| H | show a move that works |
| Esc | pause |
| R | new board |
Both input styles are live at once, because people reach for both. Swipe is the intuitive one and it is the one worth getting right:
pointermove, the moment the drag passes a third of a cell - so it
lands while your finger is still moving instead of waiting for you to lift it.An illegal swap - swiped or tapped - animates out and back rather than being silently ignored, so it is obvious the move was seen and rejected.
Same convention as Overdrive and the Angular apps: a version.json holding
{ name, version, buildDate }, the same version in a <meta name="app-version">, and
?v= on every script and stylesheet. The page polls for a newer version at launch, every
five minutes, and whenever you return to a backgrounded app, and offers a Reload.
python bump-version.py # or: python bump-version.py 1.1.0
findMatches() already returns the whole shape, so the run length is there to be read.Advertisement