The 15-puzzle for 4D, sliding on an animation engine.
Built on Hero (cs.hero), a shared element transition component.
Open the project — On Startup runs PLAY_Taquin. Slide tiles with the arrow keys (the arrow names the direction the tile travels), or click a tile next to the hole. Shuffle deals a new board.
A taquin move is a tile hopping from one known cell to another — which is exactly what a keyframe interpolator does. One tween per move, easeOutQuad, 130 ms, and that is the entire "physics" of the game:
This.transition.animate("tile_"+String($tile))\
.to({left: $slot.left; top: $slot.top; width: This.size; height: This.size})\
.duration(130)\
.easing("easeOutQuad")\
.start()No ray-casting, no smuggling — unlike ArcanoidGame, where continuous motion had to be forced into tween shape. Here the game is discrete hops.
Only one tile moves per move, so a tile can afford to be two objects: the picture and its number badge, tweened together. Two animations per move, nothing more.
This is the part worth knowing. A random permutation of a 15-puzzle is solvable only half the time. The parity of the permutation, plus the row of the hole, is an invariant that no legal move can change — so shuffling the tiles at random hands the player an impossible board on a coin flip.
So the shuffle here is not a permutation. It walks the hole through 300 random legal moves from the solved board. Every position reachable that way is, by construction, reachable back. No parity test, no re-rolling.
Measured over 20 000 shuffles (the algorithm ported out and checked against the parity invariant):
| unsolvable boards | 0 |
| corrupted boards | 0 |
| accidentally solved | 0 |
| tiles out of place | 14.0 / 15 average |
| control: random permutations | 49.9 % solvable |
tools/cut.py slices any image into the 15 tiles:
python3 tools/cut.py tools/puzzle.png # --pad 1.2 to leave more airFraming is the whole point. A logo sits on a big flat plate, and framing on the plate is what ruins the puzzle: cut the shipped 4D icon at its own bounds and eight of sixteen tiles come out as near-identical blank squares — unsolvable by eye. So the crop frames the coloured subject instead: pixels that are opaque and chromatic, counted per row and column so a shadow's tint or a few antialiased pixels cannot stretch the box. That last part matters more than it sounds — the 4D icon's drop shadow is faintly blue, and a naive bbox stretched 57 px past the logo because of it.
Framed on the subject, the ink fills the frame and only 3 of 15 tiles stay blank-ish (measured: tiles 1, 7 and 13, under 5 % coloured pixels).
It falls back to the alpha bounds, then the whole image, for pictures with no such subject — a photo is chromatic all over and frames on itself, which is correct.
Why the numbers? Even well framed, a few tiles are plain. Numbers are the classic taquin answer, and they cost nothing here since only one tile moves per move. Use a busier picture and you can drop them.
4D can also cut at runtime — TRANSFORM PICTURE($pic; Crop; x; y; w; h) works, and was verified before this was written. Cutting up front was preferred because the pieces are then plain assets that always render, with no runtime picture binding to get wrong.
Cell geometry is read from the slot_1…slot_16 objects with cs.hero.ElementState, and the tile side comes from slot_1's width. Move the slots in the form editor and the game follows — no constants to chase.
| Properties | Description | Type |
|---|---|---|
| .board | 16 cells: tile number 1–15, or 0 for the hole |
Collection |
| .empty | Index of the hole | Integer |
| .moves / .won | Current game state | Integer / Boolean |
| .slots | Cell geometry read from the form | Collection |
| Functions | Action |
|---|---|
| .shuffle () | Deals a new — always solvable — board |
| .reset () | Back to the solved picture |
| .move (direction) | "left"/"right"/"up"/"down": the way the tile travels |
| .slide (cell) | Slides the tile in that cell into the hole, if they touch |
| .tick () | Watches for a click. Call on On Timer |
Store the instance in Form.game, and keep the timer armed — the engine releases it when idle, but tick() still has to watch the mouse:
: (Form event code=On Timer)
Form.transition.onTimer()
Form.game.tick()
SET TIMER(1)4D has no On Key Down. On After Keystroke works, but — the part that costs you an afternoon — it only fires for an enterable object that currently has the focus. A form full of static pictures has nothing to focus, so the event never fires and the keyboard is dead, silently.
The fix is a decoy: InputZone, a tiny borderless input bound to an empty Form.keyboard, focused on load.
: (Form event code=On Load)
Form.keyboard:=""
GOTO OBJECT(*; "InputZone") // without this, the arrow keys do nothingAnything that steals the focus — the Shuffle button, for one — kills the keyboard until it is handed back, so the button's handler re-focuses the decoy after it runs.
