Tutorial: Puzzle Game
Build a grid-based Sokoban-style puzzle:
- A grid where crates slide across the floor
- Push crates onto goal tiles to win
- Undo/redo any number of moves
- Multiple levels loaded from JSON
- Level select screen and move counter
Estimated time: 60 minutes
Difficulty: Intermediate
1. Grid State
This game is state-centric, the entire puzzle fits in a single serializable object.
typescript
// src/puzzle/types.ts
export type CellType = 'floor' | 'wall' | 'goal' | 'goal-filled';
export interface PuzzleState {
width: number;
height: number;
grid: CellType[][]; // [row][col]
playerPos: { row: number; col: number };
crates: Array<{ row: number; col: number }>;
moves: number;
}2. Level Data
typescript
// public/levels/puzzle-01.json
{
"width": 8, "height": 6,
"playerStart": { "row": 2, "col": 1 },
"cells": [
"########",
"#......#",
"#[email protected]..#",
"#...O.X#",
"#....X.#",
"########"
],
"legend": {
"#": "wall",
".": "floor",
"@": "floor", // player start (handled separately)
"O": "floor", // crate start (handled separately)
"X": "goal"
}
}typescript
// src/puzzle/loader.ts
import type { PuzzleState, CellType } from './types.js';
interface LevelFile {
width: number;
height: number;
playerStart: { row: number; col: number };
cells: string[];
legend: Record<string, CellType>;
}
export async function loadLevel(path: string): Promise<PuzzleState> {
const file: LevelFile = await fetch(path).then((r) => r.json());
const grid: CellType[][] = [];
const crates: PuzzleState['crates'] = [];
for (let row = 0; row < file.height; row++) {
grid.push([]);
for (let col = 0; col < file.width; col++) {
const char = file.cells[row]![col] ?? '#';
// 'O' marks a crate: treat the cell underneath as floor
if (char === 'O') {
crates.push({ row, col });
grid[row]!.push('floor');
} else {
grid[row]!.push(file.legend[char] ?? 'wall');
}
}
}
return {
width: file.width,
height: file.height,
grid,
playerPos: { ...file.playerStart },
crates,
moves: 0,
};
}3. Puzzle Logic
typescript
// src/puzzle/logic.ts
import type { PuzzleState, CellType } from './types.js';
type Dir = 'up' | 'down' | 'left' | 'right';
const DELTAS: Record<Dir, [number, number]> = {
up: [-1, 0],
down: [1, 0],
left: [0, -1],
right: [0, 1],
};
function cellAt(state: PuzzleState, row: number, col: number): CellType {
return state.grid[row]?.[col] ?? 'wall';
}
function crateAt(state: PuzzleState, row: number, col: number): number {
return state.crates.findIndex((c) => c.row === row && c.col === col);
}
/** Returns a new state after the player moves in `dir`, or null if invalid. */
export function applyMove(state: PuzzleState, dir: Dir): PuzzleState | null {
const [dr, dc] = DELTAS[dir];
const newRow = state.playerPos.row + dr;
const newCol = state.playerPos.col + dc;
if (cellAt(state, newRow, newCol) === 'wall') return null;
const crateIdx = crateAt(state, newRow, newCol);
let newCrates = state.crates.map((c) => ({ ...c }));
if (crateIdx !== -1) {
// Try to push the crate
const crateRow = newRow + dr;
const crateCol = newCol + dc;
if (cellAt(state, crateRow, crateCol) === 'wall') return null;
if (crateAt({ ...state, crates: newCrates }, crateRow, crateCol) !== -1) return null;
newCrates[crateIdx] = { row: crateRow, col: crateCol };
}
return {
...state,
playerPos: { row: newRow, col: newCol },
crates: newCrates,
moves: state.moves + 1,
};
}
export function isSolved(state: PuzzleState): boolean {
const goals = state.grid.flatMap((row, r) =>
row.flatMap((cell, c) => (cell === 'goal' ? [{ row: r, col: c }] : []))
);
return goals.every((g) => state.crates.some((cr) => cr.row === g.row && cr.col === g.col));
}4. Undo/Redo History
typescript
// src/puzzle/history.ts
import type { PuzzleState } from './types.js';
export class PuzzleHistory {
private past: PuzzleState[] = [];
private future: PuzzleState[] = [];
private current: PuzzleState;
constructor(initial: PuzzleState) {
this.current = initial;
}
get state(): PuzzleState {
return this.current;
}
get canUndo(): boolean {
return this.past.length > 0;
}
get canRedo(): boolean {
return this.future.length > 0;
}
push(next: PuzzleState) {
this.past.push(this.current);
this.current = next;
this.future = []; // branch kills redo stack
}
undo(): PuzzleState | null {
const prev = this.past.pop();
if (!prev) return null;
this.future.push(this.current);
this.current = prev;
return this.current;
}
redo(): PuzzleState | null {
const next = this.future.pop();
if (!next) return null;
this.past.push(this.current);
this.current = next;
return this.current;
}
reset(state: PuzzleState) {
this.current = state;
this.past = [];
this.future = [];
}
}5. ECS Integration
typescript
// src/resources/puzzle-resource.ts
import { defineResource } from '@web-engine-dev/resources';
import { PuzzleHistory } from '../puzzle/history.js';
import { applyMove, isSolved } from '../puzzle/logic.js';
import type { Dir } from '../puzzle/logic.js';
export const PuzzleStore = defineResource('PuzzleStore', {
history: null as PuzzleHistory | null,
solved: false,
levelIndex: 0,
move(this: typeof PuzzleStore, dir: Dir) {
if (!this.history || this.solved) return;
const next = applyMove(this.history.state, dir);
if (!next) return;
this.history.push(next);
this.solved = isSolved(next);
},
undo(this: typeof PuzzleStore) {
this.history?.undo();
},
redo(this: typeof PuzzleStore) {
this.history?.redo();
},
});typescript
// src/systems/puzzle-input.ts
import { type World } from '@web-engine-dev/ecs';
import { InputManagerResource } from '@web-engine-dev/input';
import { PuzzleStore } from '../resources/puzzle-resource.js';
export function puzzleInputSystem(world: World) {
const inputMgr = world.getResource(InputManagerResource);
const store = world.getResource(PuzzleStore);
if (inputMgr.getActionDigital('up')) store.move('up');
if (inputMgr.getActionDigital('down')) store.move('down');
if (inputMgr.getActionDigital('left')) store.move('left');
if (inputMgr.getActionDigital('right')) store.move('right');
if (inputMgr.getActionDigital('undo')) store.undo();
if (inputMgr.getActionDigital('redo')) store.redo();
if (inputMgr.getActionDigital('reset')) {
// Reload current level
store.history?.reset(store.history.state);
store.solved = false;
}
}6. Rendering the Grid
Rather than spawning one entity per tile (expensive), render the grid as a single canvas draw pass:
typescript
// src/systems/puzzle-renderer.ts
import { type World } from '@web-engine-dev/ecs';
import { PuzzleStore } from '../resources/puzzle-resource.js';
const TILE = 64;
const COLORS: Record<string, string> = {
wall: '#3a2a1a',
floor: '#c8b88a',
goal: '#6a9a6a',
'goal-filled': '#2a7a2a',
};
export function puzzleRenderer(world: World) {
const store = world.getResource(PuzzleStore);
if (!store.history) return;
const state = store.history.state;
const canvas = document.querySelector<HTMLCanvasElement>('#puzzle-canvas')!;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Offset to center the grid
const offsetX = (canvas.width - state.width * TILE) / 2;
const offsetY = (canvas.height - state.height * TILE) / 2;
// Draw cells
for (let r = 0; r < state.height; r++) {
for (let c = 0; c < state.width; c++) {
ctx.fillStyle = COLORS[state.grid[r]![c]!] ?? '#000';
ctx.fillRect(offsetX + c * TILE, offsetY + r * TILE, TILE, TILE);
}
}
// Draw crates
ctx.fillStyle = '#8b5e3c';
for (const { row, col } of state.crates) {
const isOnGoal = state.grid[row]![col] === 'goal';
ctx.fillStyle = isOnGoal ? '#5a9e5a' : '#8b5e3c';
ctx.fillRect(offsetX + col * TILE + 4, offsetY + row * TILE + 4, TILE - 8, TILE - 8);
}
// Draw player
ctx.fillStyle = '#4a8fc4';
ctx.beginPath();
ctx.arc(
offsetX + state.playerPos.col * TILE + TILE / 2,
offsetY + state.playerPos.row * TILE + TILE / 2,
TILE / 2 - 8,
0,
Math.PI * 2
);
ctx.fill();
}7. Level Select
typescript
// src/ui/level-select.ts
import { UIPlugin, UINode } from '@web-engine-dev/ui';
import { PuzzleStore } from '../resources/puzzle-resource.js';
import { loadLevel } from '../puzzle/loader.js';
import { PuzzleHistory } from '../puzzle/history.js';
const LEVELS = ['/levels/puzzle-01.json', '/levels/puzzle-02.json', '/levels/puzzle-03.json'];
export function createLevelSelect(world: typeof World): UINode {
const store = world.getResource(PuzzleStore);
return {
type: 'flex',
style: { flexDirection: 'column', gap: '16px', alignItems: 'center' },
children: LEVELS.map((path, i) => ({
type: 'button',
content: `Level ${i + 1}`,
onClick: async () => {
const state = await loadLevel(path);
store.history = new PuzzleHistory(state);
store.solved = false;
store.levelIndex = i;
world.getResource(GameState).phase = 'playing';
},
style: { padding: '12px 32px', fontSize: '18px' },
})),
};
}What to Try Next
- Animations, tween crates and the player between grid cells using
@web-engine-dev/tween - Sound effects, play a "clunk" on crate push, "fanfare" on solve using
@web-engine-dev/audio - High scores, save the best move count per level using
@web-engine-dev/save - Level editor, let players draw their own puzzles and share them
- Timed challenge mode, add a countdown timer that increases pressure
Congratulations, you've completed all the core tutorials! Head to the Game Dev Guide for deep dives into every engine system.