TinyGames · how it works

MatchMania

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

Why it exists

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 no-moves rule

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.

It is very easy to get this wrong

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.


Gems

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.


Scoring

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.


Controls

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:

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.


Versioning

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

Things deliberately left undone