Skip to main content

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​

packThe gameInputThe round ends when
blockdrag pieces from a tray onto the board; full rows and columns cleardragno piece in the tray fits
mergesliding number tiles: every tile slides, two equal tiles merge into the next valueswipeno swipe changes the board, or merge.target is reached
matchinput: 'tap': tap a group of one colour to pop it. input: 'swap': swap two neighbours to line up three, with cascadestap or dragno move is left after shuffles; in level mode, the moves run out
sortpour colours between tubes until each tube holds one colourtap a tube, then anotherevery 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​

FieldTypeDefaultMeaning
idstringrequiredURL-safe id. Also seeds the level table and the daily board.
titlestringrequiredShown on the title screen.
descriptionstring''One sentence for the game's card.
badgestringan emoji per packCard metadata; the game never shows it.
pack'block' | 'merge' | 'match' | 'sort'requiredThe 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.
dailybooleanfalseEveryone gets the same board each day, seeded by the date, with a daily streak and a per-day leaderboard.
seednumberrandom per roundA fixed board, for tests or an authored puzzle.
board{ rows?, cols? }per packBoard size, see below. sort ignores it.
blockobjectsee belowBlock pack options.
mergeobjectsee belowMerge pack options.
matchobjectsee belowMatch pack options.
sortobjectsee belowSort pack options.
levelsobjectsee belowLevel mode options.
themePuzzleThemenight blueThe look, see below.
soundsPartial<Record<PuzzleSound, AudioSource>>placeholder soundsReplace any sound with your own require(...), see Sounds.
fonts{ display?: GameFont; ui?: GameFont }Fredoka bold and semiboldFaces 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.
hintstringone line per packShown under the title and as the first-run hint.

board​

PackDefaultRange
block8 x 8rows and cols 6 to 10
merge4 x 4rows and cols 3 to 6
match8 rows x 7 colsrows 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​

FieldTypeDefaultMeaning
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.
traynumber3Pieces per tray, 1 to 3.
colorsnumber6Piece colours, 1 to the palette length.
fairTraybooleantrueRefill the tray so at least one piece fits whenever any piece could.

merge​

FieldTypeDefaultMeaning
spawnFournumber0.1Chance a new tile is a 4 instead of a 2, 0 to 0.5.
targetnumber0 (endless)Reaching this value wins the round. Snapped down to a power of two, 8 to 131072.
startTilesnumber2Tiles on the board at the start, 1 to 4.

match​

FieldTypeDefaultMeaning
input'tap' | 'swap''tap'Pop groups by tapping, or swap neighbours to make lines of three, with cascades.
minClusternumber2 for tap, 3 for swapSmallest group a move clears, 2 to 4.
colorsnumber5Colours on the board, 3 to 7. Fewer is easier.

sort​

FieldTypeDefaultMeaning
colorsnumber5Colours, one full tube each, 3 to 10.
capacitynumber4Units per tube, 3 to 6.
emptynumber2Spare empty tubes, 1 to 3. Fewer is harder.
undonumber3Undos per round, 0 to 99. Only the sort pack has undo.

levels​

FieldTypeDefaultMeaning
budget'moves' | 'seconds''moves'What runs out in level mode.
difficultynumber1How fast levels get harder, 0.5 (gentle) to 2 (steep).

theme​

FieldTypeDefaultMeaning
backgroundCSS colour'#0F1024'Screen background.
boardCSS colour'#1B1D3A'The panel behind the cells.
cellCSS colour'#262A4F'An empty cell.
textCSS colour'#F3F4FF'HUD text.
accentCSS colour'#2EE6D6'Buttons and highlights. Also the game's accent.
palettestring[]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.
marksbooleanfalseDraws a shape per colour (circle, triangle, square, diamond, star and more), so colour is never the only cue.
namesstring[]noneA name per kind (merge: per value step), used in goals and results, for example "Reach Full Moon".
paintTileTilePainternoneYour own tile art, see below.
paintBackgroundBackgroundPainternoneAn 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.

TypeSignatureCalled
TilePainter(draw, tile, x, y, size, theme) => voidonce 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) => voidevery 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:

PackGoalGets harder by
blockClear N linesmore lines, fewer spare moves
mergeReach a value (or its name from theme.names)a higher value
matchClear N tilesmore tiles, more colours
sortSort every tubemore 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:

KeyFilePlays on
placeplace.wava block placed
selectselect.wava tube picked, a swap
clearclear.wavlines or tiles cleared
combocombo.wava combo or cascade
mergemerge.wavtwo tiles merged
pourpour.wava pour
invalidinvalid.wavan illegal move
levelUplevelup.wavlevel mode: a first win of the newest level, just after win, as the next level opens (not on a replay)
winwin.wavthe round won (solved or goal)
failfail.wavthe round lost
sounds: { merge: require('../../assets/sfx/my-merge.wav') },

What you can customise​

WhatHow
Rules and difficultypack, the pack's options, board, mode, levels, daily.
Looktheme colours and tileStyle, marks, fonts.
Tile arttheme.paintTile, drawn shapes, or cells of a sprite sheet drawn inside it. Avoid emoji and text characters as tile art.
Backgroundtheme.paintBackground.
Wordstheme.names, hint, title.
Soundssounds. Haptics come with each event and follow the player's setting.
A new ruleThe 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) and daily-YYYY-MM-DD (that day's score). The backend scaffold's BOARDS already lists endless, levels and 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​

  • palette needs at least as many colours as the pack uses. match.colors and sort.colors are capped by the palette length, so a short palette quietly makes the game easier.
  • merge.target is snapped down: target: 1000 becomes 512.
  • A paintTile that ignores tile.kind makes every colour look the same. Draw a different shape or colour per kind.
  • seed fixes every round to the same board. Leave it out for real play.
  • Keep definePuzzleGame as the default export; rebuilding the screens yourself loses the title, results, levels and AUTO.