Phaser AE

Box-pushing puzzles

Free packv3.0.0-32DGrid

The crate-pushing puzzle: walk the keeper round a warehouse and push crates, never pulling, until every one is on a goal. The pack plays authored levels with full undo, tells the player when a level can no longer be won, and solves levels for hints.

This is a Free pack. Every phaser.io account can use it in their games.

Released October 14, 2026 · updated October 20, 2026

Box-pushing puzzles: a screenshot of its demo
A note about these graphics

Expansion packs don't come with graphics. This screenshot is one way of drawing what the pack does, made for its demo: none of the sprites, tiles or backgrounds in it are in the pack.

That's by design. A pack is a game's logic, mechanics and effects, and it works with whatever art you give it. Some packs draw effects, backdrops or 3D scenes of their own, but none carries a game's sprites.

So the look is yours. Make the art on phaser.io, bring your own, or let your agent draw it in code: the same pack can look like anything.

The pack

pusher

function pusher(): Pack<{ pusher: PusherApi }>

The pusher pack. Pure logic, so it needs no backend and runs headless.

Helpers

createPusher

function createPusher(cfg: PusherConfig): Pusher

Creates a Pusher puzzle from a level. Throws on a level it cannot read (see PusherConfig.level).

parseLevels

function parseLevels(text: string): PusherLevel[]

Read a level collection in the common .sok / .xsb / .txt text form: each level is a block of rows of XSB characters (a row is one that contains a wall and nothing but XSB characters); other lines are names and notes. The last such line before a level names it, and a Title: ... line after one names it if nothing did. Lines starting with ; are comments. It reads rows only; whether each level is valid is createPusher's check.

Classes

Pusher

class Pusher

A Sokoban puzzle: push boxes onto goals on a grid. Create one with createPusher.

  • readonly cols: number — The number of columns in the level grid.
  • readonly rows: number — The number of rows in the level grid.
  • constructor(cfg: PusherConfig)
  • get moves(): number — The number of player moves made so far. Undo decreases it.
  • get pushes(): number — The number of box pushes made so far. Undo decreases it.
  • isWall(x: number, y: number): boolean — Returns true when the cell is a wall. Cells outside the level count as walls.
  • isGoal(x: number, y: number): boolean — Returns true when the cell is a goal square.
  • isBox(x: number, y: number): boolean — Returns true when a box sits on the cell.
  • isDead(x: number, y: number): boolean — Returns true when the cell is a dead square: floor inside the level from which no box can ever be pushed to a goal.
  • get playerPos(): XY — The grid position of the player, as a copy.
  • get metrics(): { moves: number; pushes: number } — The move and push counts, as one object.
  • get solved(): boolean — Solved when every box sits on a goal.
  • get deadlocked(): boolean — Whether the level can no longer be won: a box off its goal sits on a dead square or is frozen (see isFrozen). It catches the simple and freeze deadlocks, not every lost position.
  • get canUndo(): boolean — Whether there is a move to undo.
  • get canRedo(): boolean — Whether there is an undone move to redo.
  • move(dir: Dir): MoveResult — Move the player in dir, pushing a single box if one is ahead. A move into a wall or a blocked box changes nothing. A move clears the redo history.
  • undo(): boolean — Undo the last move (pulling a pushed box back). False when there is none.
  • redo(): boolean — Redo an undone move. False when there is none.
  • reset(): void — Return to the level's start and clear the undo and redo history.
  • isFrozen(x: number, y: number): boolean — Is the box at (x, y) frozen: unable ever to move along either axis, because a wall, two dead squares or a box that is itself frozen the other way blocks it? A frozen box off a goal means the level is lost (deadlocked). False when no box is there.
  • solution(maxStates = 200000): PusherSolution — Solve from the current position, for a hint or to check a level, without changing it: a breadth-first search over box positions, one push per step (the player's walk between pushes is free, so positions the player can walk between count as one), skipping pushes onto dead squares and into frozen deadlocks. The answer has the fewest pushes any solution needs, though not necessarily the fewest moves. maxStates (default 200,000) bounds the box positions kept.
  • solve(maxStates = 200000): Dir[] | null — The moves of solution(maxStates) (default 200,000 box positions): fewest pushes, from the current position. [] when already solved; null when there is no solution or the budget ran out (see solution to tell which).
  • debugGrid(): string — Returns the level as XSB text, one row for each line. Use it for tests and logs.

Types

Dir

type Dir = 'up' | 'down' | 'left' | 'right'

A direction the player can move in on the grid.

MoveResult

interface MoveResult

The outcome of one Pusher.move call.

  • moved: boolean — True when the player moved. False when a wall or a blocked box stopped the move.
  • pushed: { from: XY; to: XY } | null — The box push made by this move — its start and end cells — or null when no box moved.
  • ontoDeadSquare: boolean — True when the move pushed a box onto a dead square that is not a goal.
  • deadlocked: boolean — True when the level can no longer be won after this move (see Pusher.deadlocked).
  • solved: boolean — True when every box now sits on a goal.

PusherApi

type PusherApi = typeof kit

game.pusher — every function and type this pack carries, as one namespace.

PusherConfig

interface PusherConfig

Configures createPusher with the level to load.

  • level: string[] — The level, one string per row, in XSB: # wall, space, - or _ floor, @ player, + player on a goal, $ box, * box on a goal, . goal. Rows may differ in length (the rest is floor). It needs exactly one player and as many boxes as goals, at least one; any other character throws.
  • legend?: Partial<PusherLegend> — Other characters for the same things, merged over XSB's (only the entries you give change).

PusherLegend

interface PusherLegend

Which characters of a level mean what. Each entry lists every character that means it.

  • wall: string — Default '#'.
  • floor: string — Default ' -_'.
  • goal: string — Default '.'.
  • box: string — Default '$'.
  • boxOnGoal: string — A box on a goal; default '*'.
  • player: string — Default '@'.
  • playerOnGoal: string — The player on a goal; default '+'.

PusherLevel

interface PusherLevel

One level of a collection, from parseLevels.

  • title?: string — The level's name, when the collection gives one.
  • level: string[] — The rows, ready for createPusher({ level }).

PusherSolution

interface PusherSolution

The result of Pusher.solution.

  • status: 'solved' | 'unsolvable' | 'budget-exhausted' — solved (with the moves), unsolvable (no push sequence wins: proven) or budget-exhausted (no claim).
  • moves: Dir[] — The player's moves, walking and pushing, from the current position; empty unless solved.
  • pushes: number — Pushes in moves: the fewest any solution needs.
  • visited: number — Box positions examined.

XY

interface XY

A grid cell position.

  • x: number — The column of the cell.
  • y: number — The row of the cell.