Skip to content

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.

Proprietary software. All rights reserved.