# Puzzle module

> definePuzzleGame builds a complete 2D grid puzzle from one config, with four rule packs (block, merge, match, sort), endless or level play, a daily puzzle, every screen, sounds, haptics and a leaderboard.

`definePuzzleGame(config)` makes a whole grid puzzle on the engine's [2D layer](https://docs.moonspell.app/engine/2d.md): 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.

```tsx
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

```tsx
// 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

```tsx
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](https://docs.moonspell.app/modules/puzzle.md#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](https://docs.moonspell.app/engine/2d.md)) 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](https://docs.moonspell.app/engine/saves.md).

## 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 |

```tsx
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) 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](https://docs.moonspell.app/backend/overview.md) and [Networking](https://docs.moonspell.app/engine/net.md).

## 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 `testID`s: `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.
