Puzzle module
definePuzzleGame(config) makes a whole grid puzzle on the engine's 2D layer: the rules, the board, input, animation, sounds, haptics, a title screen with the board playing itself behind it, the HUD, pause, results with a leaderboard and a share card, numbered levels with stars, a daily puzzle with a streak, and saved progress. Use it for one-thumb puzzle games in portrait.
import { definePuzzleGame } from '@/engine/modules/puzzle';
import type { PuzzleConfig, TilePainter, BackgroundPainter } from '@/engine/modules/puzzle';
Source: templates/crescent-game/src/engine/modules/puzzle/.
The four rule packs
pack | The game | Input | The round ends when |
|---|---|---|---|
block | drag pieces from a tray onto the board; full rows and columns clear | drag | no piece in the tray fits |
merge | sliding number tiles: every tile slides, two equal tiles merge into the next value | swipe | no swipe changes the board, or merge.target is reached |
match | input: 'tap': tap a group of one colour to pop it. input: 'swap': swap two neighbours to line up three, with cascades | tap or drag | no move is left after shuffles; in level mode, the moves run out |
sort | pour colours between tubes until each tube holds one colour | tap a tube, then another | every tube is one colour (every deal is solvable) |
The smallest complete game
// src/game/index.tsx
import { definePuzzleGame } from '@/engine/modules/puzzle';
export default definePuzzleGame({
id: 'moon-blocks',
title: 'Moon Blocks',
description: 'Drag blocks onto the board and clear lines under the moon.',
pack: 'block',
});
That is an endless block puzzle on an 8 x 8 board. Change pack to 'merge', 'match' or 'sort' for the other games. examples/moon-blocks/ is this game with a night-sky theme.
A themed game with levels and drawn tiles
import { definePuzzleGame, type BackgroundPainter, type TilePainter } from '@/engine/modules/puzzle';
import { orbitronBlack } from '@/engine/ui/fonts/orbitronBlack';
import { orbitronBold } from '@/engine/ui/fonts/orbitronBold';
const paintTile: TilePainter = (draw, tile, x, y, size, theme) => {
const cx = x + size / 2;
const cy = y + size / 2;
draw.disc(cx, cy, size * 0.4, { color: theme.palette[tile.kind % theme.palette.length] });
};
const paintBackground: BackgroundPainter = (draw, width, height, time) => {
for (let i = 0; i < 30; i++) {
const x = ((i * 97) % 100) / 100;
const y = ((i * 61) % 100) / 100;
draw.disc(x * width, y * height, 1.2, { color: '#FFFFFF', alpha: 0.3 + 0.3 * Math.abs(Math.sin(time + i)) });
}
};
export default definePuzzleGame({
id: 'star-merge',
title: 'Star Merge',
description: 'Merge stars into bigger stars.',
pack: 'merge',
mode: 'levels',
merge: { spawnFour: 0.08 },
levels: { budget: 'moves', difficulty: 1 },
theme: { background: '#0A0B1E', names: ['Spark', 'Ember', 'Star'], paintTile, paintBackground },
fonts: { display: orbitronBlack, ui: orbitronBold },
hint: 'Swipe the sky. Two matching stars become the next one.',
});
examples/moonrise/ (merge, levels, twelve drawn bodies) and examples/gem-cascade/ (match in swap mode, six gem cuts) are complete versions of this pattern.
Config reference
Every number is rounded and clamped into its range.
Top level
| Field | Type | Default | Meaning |
|---|---|---|---|
id | string | required | URL-safe id. Also seeds the level table and the daily board. |
title | string | required | Shown on the title screen. |
description | string | '' | One sentence for the game's card. |
badge | string | an emoji per pack | Card metadata; the game never shows it. |
pack | 'block' | 'merge' | 'match' | 'sort' | required | The rules. An unknown value falls back to block. |
mode | 'endless' | 'levels' | 'endless' | High-score play, or numbered levels with a goal, a budget and stars. |
daily | boolean | false | Everyone gets the same board each day, seeded by the date, with a daily streak and a per-day leaderboard. |
seed | number | random per round | A fixed board, for tests or an authored puzzle. |
board | { rows?, cols? } | per pack | Board size, see below. sort ignores it. |
block | object | see below | Block pack options. |
merge | object | see below | Merge pack options. |
match | object | see below | Match pack options. |
sort | object | see below | Sort pack options. |
levels | object | see below | Level mode options. |
theme | PuzzleTheme | night blue | The look, see below. |
sounds | Partial<Record<PuzzleSound, AudioSource>> | placeholder sounds | Replace any sound with your own require(...), see Sounds. |
fonts | { display?: GameFont; ui?: GameFont } | Fredoka bold and semibold | Faces from @/engine/ui/fonts. Display is used for the title, score and tile numbers, UI for buttons and labels. Use two weights of one family. |
hint | string | one line per pack | Shown under the title and as the first-run hint. |
board
| Pack | Default | Range |
|---|---|---|
block | 8 x 8 | rows and cols 6 to 10 |
merge | 4 x 4 | rows and cols 3 to 6 |
match | 8 rows x 7 cols | rows 6 to 10, cols 5 to 9 |
For block and merge, cols defaults to rows, so board: { rows: 9 } is a 9 x 9 board.
block
| Field | Type | Default | Meaning |
|---|---|---|---|
pieces | 'classic' | 'small' | 'tetris' | string[] | 'classic' | The shapes the tray deals: a named set from PIECE_SETS, or a list of names from PIECES. Unknown names are dropped. small is gentler, tetris is tetrominoes only. |
tray | number | 3 | Pieces per tray, 1 to 3. |
colors | number | 6 | Piece colours, 1 to the palette length. |
fairTray | boolean | true | Refill the tray so at least one piece fits whenever any piece could. |
merge
| Field | Type | Default | Meaning |
|---|---|---|---|
spawnFour | number | 0.1 | Chance a new tile is a 4 instead of a 2, 0 to 0.5. |
target | number | 0 (endless) | Reaching this value wins the round. Snapped down to a power of two, 8 to 131072. |
startTiles | number | 2 | Tiles on the board at the start, 1 to 4. |
match
| Field | Type | Default | Meaning |
|---|---|---|---|
input | 'tap' | 'swap' | 'tap' | Pop groups by tapping, or swap neighbours to make lines of three, with cascades. |
minCluster | number | 2 for tap, 3 for swap | Smallest group a move clears, 2 to 4. |
colors | number | 5 | Colours on the board, 3 to 7. Fewer is easier. |
sort
| Field | Type | Default | Meaning |
|---|---|---|---|
colors | number | 5 | Colours, one full tube each, 3 to 10. |
capacity | number | 4 | Units per tube, 3 to 6. |
empty | number | 2 | Spare empty tubes, 1 to 3. Fewer is harder. |
undo | number | 3 | Undos per round, 0 to 99. Only the sort pack has undo. |
levels
| Field | Type | Default | Meaning |
|---|---|---|---|
budget | 'moves' | 'seconds' | 'moves' | What runs out in level mode. |
difficulty | number | 1 | How fast levels get harder, 0.5 (gentle) to 2 (steep). |
theme
| Field | Type | Default | Meaning |
|---|---|---|---|
background | CSS colour | '#0F1024' | Screen background. |
board | CSS colour | '#1B1D3A' | The panel behind the cells. |
cell | CSS colour | '#262A4F' | An empty cell. |
text | CSS colour | '#F3F4FF' | HUD text. |
accent | CSS colour | '#2EE6D6' | Buttons and highlights. Also the game's accent. |
palette | string[] | ten bright hues (merge: a warm ramp by value) | Tile colours by kind. For merge, one colour per value step (2, 4, 8 and so on). Give at least as many as the pack uses. Exported as DEFAULT_PALETTE and MERGE_PALETTE. |
tileStyle | 'glossy' | 'flat' | 'round' | 'glossy' | The default tile's look. |
marks | boolean | false | Draws a shape per colour (circle, triangle, square, diamond, star and more), so colour is never the only cue. |
names | string[] | none | A name per kind (merge: per value step), used in goals and results, for example "Reach Full Moon". |
paintTile | TilePainter | none | Your own tile art, see below. |
paintBackground | BackgroundPainter | none | An animated background behind the board, see below. |
Painters
Both painters draw with the 2D layer's Draw2D API (disc, poly, line, ring, clip and more, see 2D layer) in stage units.
| Type | Signature | Called |
|---|---|---|
TilePainter | (draw, tile, x, y, size, theme) => void | once per tile per frame. tile is { kind, value? }; (x, y) is the tile's top-left corner and size its side. The module applies scale and fade around your drawing. |
BackgroundPainter | (draw, width, height, time, theme) => void | every frame behind the board. time is in seconds. |
theme in both is the resolved theme, so theme.palette and theme.accent are always set.
Levels and the daily puzzle
mode: 'levels' generates numbered levels from the game's id, so a game always has the same levels:
| Pack | Goal | Gets harder by |
|---|---|---|
block | Clear N lines | more lines, fewer spare moves |
merge | Reach a value (or its name from theme.names) | a higher value |
match | Clear N tiles | more tiles, more colours |
sort | Sort every tube | more colours |
The budget is moves (or seconds with levels.budget: 'seconds'). Stars come from what is left: 3 stars with at least half the budget left, 2 with a fifth, otherwise 1. Sort levels are scored against the solver's par instead: 3 stars at par, 2 within one and a half times par.
daily: true seeds the board from the date. Everyone who plays that day gets the same board, the streak grows once per day, and the leaderboard is that day's.
Progress (best score, level reached, stars, streak) is kept in an engine save slot, so it survives app updates and syncs to the player's other devices when the game has a backend. Sound and vibration toggles in the pause menu are the engine's settings, see Saves.
Sounds
The defaults are short synth placeholders in assets/sfx/puzzle/. The keys of sounds (PuzzleSound) and their default files:
| Key | File | Plays on |
|---|---|---|
place | place.wav | a block placed |
select | select.wav | a tube picked, a swap |
clear | clear.wav | lines or tiles cleared |
combo | combo.wav | a combo or cascade |
merge | merge.wav | two tiles merged |
pour | pour.wav | a pour |
invalid | invalid.wav | an illegal move |
levelUp | levelup.wav | level mode: a first win of the newest level, just after win, as the next level opens (not on a replay) |
win | win.wav | the round won (solved or goal) |
fail | fail.wav | the round lost |
sounds: { merge: require('../../assets/sfx/my-merge.wav') },
What you can customise
| What | How |
|---|---|
| Rules and difficulty | pack, the pack's options, board, mode, levels, daily. |
| Look | theme colours and tileStyle, marks, fonts. |
| Tile art | theme.paintTile, drawn shapes, or cells of a sprite sheet drawn inside it. Avoid emoji and text characters as tile art. |
| Background | theme.paintBackground. |
| Words | theme.names, hint, title. |
| Sounds | sounds. Haptics come with each event and follow the player's setting. |
| A new rule | The packs are pure functions in packs/ (block.ts, merge.ts, match.ts, sort.ts). Each implements legalMoves, apply (returns the new state, its events and the boards to animate through), isOver and solve. Add your rule there with a test. The screens (PuzzleScreens.tsx) and the controller (controller.ts) stay as they are. |
The pure parts are exported for tests and tools: initPuzzle, applyMove, solveMove, legalMoves, overReason, autoplay, levelFor, goalProgress, starsFor, budgetSpent, dailySeed, dateKey, resolvePuzzleConfig, PIECES, PIECE_SETS, PACK_HINTS, EFFECTS, DEFAULT_SOUNDS and drawMark.
Backend
The module needs no backend code of its own. With GAME_BACKEND_URL set:
- The results screen shows the live top five and your rank on one of three boards, all highest first:
endless(score),levels(level reached) anddaily-YYYY-MM-DD(that day's score). The backend scaffold'sBOARDSalready listsendless,levelsand the"daily-*"family. - The title screen and the HUD show "N playing now" from presence.
- Progress syncs through the player's save.
Without a backend the same lists show this device's best runs. See the backend overview and Networking.
Dev and testing
npm test covers the packs, level generation, the daily seed and progress (levels.test.ts, packs.test.ts).
In development builds, the title shows an AUTO button (btn-autoplay) that plays the solver, and the play screen shows a telemetry line with the pack, score, moves, legal moves, combo and the board geometry in pixels (bpx board rectangle, cpx cell pitch, tray slot or tube centres), so an automation tool can drag, swipe and tap.
Other testIDs: puzzle-title, btn-play, btn-title-exit, hud-score, hud-status, hud-right, hud-hint, btn-pause, puzzle-pause, btn-resume, btn-sound, btn-haptics, btn-pause-exit, btn-undo, puzzle-results, hud-finished, hud-result-score, hud-leaderboard, btn-share, btn-restart, btn-finish-exit.
Gotchas
paletteneeds at least as many colours as the pack uses.match.colorsandsort.colorsare capped by the palette length, so a short palette quietly makes the game easier.merge.targetis snapped down:target: 1000becomes 512.- A
paintTilethat ignorestile.kindmakes every colour look the same. Draw a different shape or colour per kind. seedfixes every round to the same board. Leave it out for real play.- Keep
definePuzzleGameas the default export; rebuilding the screens yourself loses the title, results, levels and AUTO.