Phaser AE

Trail-wall duels

Pro packv3.0.0-32DArcade

Grid trail games as rules: riders move at once, every move leaves a wall, and the last one riding wins. A match runs rounds and scoring, and the bots play for territory, so a duel against one feels like a duel.

This is a Pro pack. Upgrade to Phaser Pro to use it in your games.See Phaser Pro →

Released October 14, 2026 · updated October 20, 2026

Trail-wall duels: 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

lightCycle

function lightCycle(): Pack<{ lightCycle: typeof api }>

Install the renderer-independent light-cycle API: rules, bots, presets and best-of-N matches.

Helpers

createLightCycle

function createLightCycle(config: LightCycleConfig)

Create an independent grid trail game for any supported population and layout.

createLightCycleBot

function createLightCycleBot( id: string, profile: BotDifficulty | Partial<BotProfile> = "normal", seed = 1, )

Create a seeded bot for one rider.

createLightCycleMatch

function createLightCycleMatch(config: LightCycleMatchConfig)

Create a best-of-N light-cycle match.

lightCycleBotDirection

function lightCycleBotDirection( game: LightCycle, id: string, profile: BotDifficulty | Partial<BotProfile> = "normal", seed = 1, ): Direction

One stateless decision (no reaction delay or memory), e.g. for a hint or a demo rider.

lightCycleBotProfile

function lightCycleBotProfile( p: BotDifficulty | Partial<BotProfile> = "normal", ): BotProfile

Resolve a difficulty name or partial profile (over Normal) to a validated full profile.

lightCycleBotProfiles

const lightCycleBotProfiles: Record<BotDifficulty, BotProfile>

Easy / Normal / Hard presets.

lightCycleHeadings

const lightCycleHeadings: readonly Direction[]

The four headings in clockwise order; a right turn is the next entry, a left turn the previous.

lightCycleObstacles

function lightCycleObstacles( layout: LightCycleLayout, cols: number, rows: number, o: LightCycleObstacleOptions = {}, ): Cell[]

Generate a four-way symmetric obstacle layout for any board size. Starts and the lane in front of each start are always left free, so every generated arena is fair to every seat.

lightCyclePreset

function lightCyclePreset( kind: "classic" | "duel" | "arena" = "duel", options: LightCyclePresetOptions = {}, ): LightCycleConfig

Authored starting points; every field can be overridden.

  • classic — the 1982 arcade light-cycle grid: bounded, open, the player against one to three rivals (default one), crashed cycles derez with their walls.
  • duel — two riders, bounded, open grid, walls stay.
  • arena — four riders on a wrapping board with pillars; crashed walls derez.

lightCycleStarts

function lightCycleStarts( count: number, cols: number, rows: number, ids?: readonly string[], ): RiderDefinition[]

Starting cells for count riders, spaced evenly around an ellipse inset from the edges and facing the centre along the dominant axis. Two riders sit at mid-left and mid-right facing each other (the film's and the arcade's duel); more riders are rotated half a slot so no two face head-on down the same line. ids names them; the default is p1, p2, ...

turnHeading

function turnHeading(d: Direction, turn: "left" | "right"): Direction

The heading after a relative 90-degree turn, as a rotary-knob or two-button scheme steers.

Classes

LightCycle

class LightCycle

Owns simultaneous movement, permanent occupancy and explicit trail-game outcomes.

  • readonly config: LightCycleConfig — A copy of the configuration, as validated.
  • lastStep: LightCycleStepEvents — Events of the most recent step().
  • constructor(cfg: LightCycleConfig)
  • get state(): LightCycleState — A deep copy of the whole state (serializable). Prefer rider()/ownerAt() in per-frame code.
  • get ticks() — Steps taken so far.
  • get status() — 'playing', 'won' or 'draw'; stepping does nothing once it is no longer 'playing'.
  • rider(id: string): RiderState — A copy of one rider's state.
  • heads(): { id: string; at: Cell; direction: Direction; next: Direction; alive: boolean; }[] — Cheap per-frame summary of every rider: head, current heading, next (first buffered) heading.
  • ownerAt(x: number, y: number): string | null — Who holds a cell: a rider id, "obstacle" or null when free. Out-of-range cells report "wall".
  • view(): LightCycleGridView — A copy of the occupancy grid (row-major) for bots and renderers.
  • outcome(id: string): "playing" | "won" | "lost" | "draw" — Per-rider result, so a local player can restart immediately after elimination in a larger match.
  • plannedDirection(id: string): Direction — The heading the rider will have after its buffered turns: the reference for the next turn.
  • input(id: string, d: Direction): boolean — Buffer an absolute turn. Turns are kept in press order (up then left within one step both happen, on consecutive moves). A turn equal to, or reversing, the planned heading is refused, as is one beyond the turnQueue capacity. Returns whether the turn was accepted.
  • turn(id: string, side: "left" | "right"): boolean — Buffer a relative 90-degree turn from the planned heading (rotary-knob or two-button steering).
  • clearTurns(id: string) — Drop a rider's buffered turns.
  • willMove(id: string): boolean — Whether this rider moves on the next step() (always, at speed 1). Bots decide on these steps.
  • setSpeed(id: string, speed: number) — Change a rider's speed in (0, 1] cells per step, for boost/brake or per-difficulty pacing.
  • step(): LightCycleState — Resolve every moving rider against the same pre-step occupancy, then commit together.
  • advance(): LightCycleStepEvents — step() without the state copy; returns the step's events.
  • snapshot() — A deep copy of the whole state, for restore.
  • restore(s: LightCycleState) — Loads a state from snapshot and rebuilds the occupancy. Throws if the riders, status, cells, headings, speeds, progress or winners do not fit this board.
  • reset() — Returns to the starting cells with no trails or buffered turns.

LightCycleBot

class LightCycleBot

A deterministic light-cycle opponent. Create one per bot rider; call decide(game) once per step, before game.step(), and feed the result to game.input(id, d) when it is not null.

Strategy (in the spirit of the classic Tron AI contests):

  1. Moves into solid cells lose; simultaneous head-ons are scored as a draw and avoided.
  2. While any opponent can reach the bot, it searches lookahead joint plies against the nearest one (minimax: it assumes that opponent replies as badly for it as possible) and scores leaves by Voronoi territory with a parity-limited fill estimate — so it closes space off when it is ahead.
  3. When walled off from everyone, it fills its own region: depth-first lookahead that never steps into the smaller side of a split, preferring cells beside walls (wall hugging).
  4. A straight-ahead bias and a re-plan interval keep it from zig-zagging through open space.
  • readonly profile: BotProfile — The validated full profile in use, with any partial profile merged over Normal.
  • lastScores: Partial<Record<Direction, number>> — The value of each candidate at the last full plan, for debugging and tests.
  • constructor( readonly id: string, profile: BotDifficulty | Partial<BotProfile> = "normal", seed = 1, )
  • reset() — Forget timing memory (new round).
  • decide(game: LightCycle, budget = Infinity): Direction | null — Choose a heading for the next step, or null to keep the planned one. budget caps this decision's cell visits below the profile's own (a match shares one per-step budget between its bots).

LightCycleMatch

class LightCycleMatch

Rounds, scoring, step timing, acceleration and bot driving around one LightCycle. The host maps keys to input/turn/boost, Space to start(), P to pause(), and reads game to draw.

  • readonly config: LightCycleMatchConfig — A copy of the configuration, as validated.
  • readonly humans: string[] — Ids of the riders steered by input, turn and boost; every other rider is a bot.
  • phase: LightCycleMatchPhase — Where the match stands: waiting to start, counting down, playing, a round result, or the match result.
  • paused: boolean — Whether the clock is frozen; set by pause and cleared by start.
  • round: number — The current round number, counting from 1.
  • scores: Record<string, number> — Rounds won so far, by rider id.
  • roundWinners: string[] — Riders that took the last finished round (empty on a draw).
  • matchWinner: string | null — The match winner once the match is over.
  • constructor(cfg: LightCycleMatchConfig)
  • get game(): LightCycle — The current round's rules object: read it to draw (riders, trails via ownerAt, heads()).
  • get tickRate(): number — Current steps per second including acceleration.
  • get countdown(): number — Seconds left of the READY countdown.
  • start(): boolean — Begin: from ready (first round), after a round (next round) or after the match (new match).
  • newMatch() — Back to round one with zero scores, waiting for start().
  • restartRound() — Replay the current round from its start (scores unchanged), waiting for start().
  • pause(on = !this.paused) — Toggle (or set) pause; only meaningful while counting down or playing.
  • input(id: string, d: Direction): boolean — Buffer an absolute turn for a human rider.
  • turn(id: string, side: "left" | "right"): boolean — Buffer a relative turn for a human rider.
  • boost(id: string, on: boolean) — Hold or release a human's boost (no effect unless boostSpeed is configured).
  • update(dt: number): LightCycleMatchEvents — Advance real time: countdown, then fixed steps at the current rate with bots deciding first.
  • tick(): LightCycleStepEvents — One simulation step regardless of real time (tests, replays, headless tournaments).
  • roundResult(id: string): "won" | "lost" | "draw" | null — The human-facing result of the last round for one rider.

Types

BotDifficulty

type BotDifficulty = "easy" | "normal" | "hard"

Named bot strengths. Each is a BotProfile; any field can be overridden.

BotProfile

interface BotProfile

The knobs of the light-cycle bot. All decisions are deterministic for a given seed.

  • lookahead — joint plies (both riders move) searched against the nearest reachable opponent, 0–4. 0 evaluates only the bot's own three moves with opponents standing still.

  • aggression — 0..1. 0 plays for its own reachable space; 1 plays purely for Voronoi territory (cells it reaches before every opponent), which is what drives cutting an opponent off.

  • reactionTicks — steps between re-plans. Between plans the bot holds its heading unless the cell ahead is solid. 0 re-plans every step.

  • errorRate — 0..1 chance, per re-plan, of a lapse: the bot holds its heading for that step (only while the next cell is safe) instead of acting on its plan, so it turns late and gets cut off. It never steers into a wall on purpose and never wiggles.

  • straightBias — value (in cells) of keeping the current heading, so open space is crossed in straight runs instead of zig-zags.

  • fillDepth — moves of depth-first lookahead when walled off from every opponent (space filling).

  • budget — approximate cell visits allowed per decision (a quarter of it for space filling). Search depth is reduced until the estimate fits, so a huge arena degrades gracefully instead of stalling.

  • lookahead: number — Joint plies searched against the nearest reachable opponent, a whole number 0 to 4. Presets 0, 1 and 3.

  • aggression: number — 0 to 1: 0 plays for its own space, 1 for the Voronoi territory margin. Presets 0.1, 0.55 and 1.

  • reactionTicks: number — Steps between re-plans, a whole number 0 to 1000; 0 re-plans every step. Presets 3, 0 and 0.

  • errorRate: number — Chance, 0 to 1, per re-plan of a lapse: holding the heading for a step while it is safe. Presets 0.2, 0.03 and 0.

  • straightBias: number — Value, in cells, of keeping the current heading; 0 or more. Presets 6, 3 and 1.5.

  • fillDepth: number — Moves of depth-first space-filling lookahead when walled off from every opponent, a whole number 0 to 12. Presets 2, 5 and 7.

  • budget: number — Approximate cell visits allowed per decision, a whole number 1000 to 1e9; search depth shrinks to fit. Presets 200,000, 800,000 and 4,000,000.

LightCycleConfig

interface LightCycleConfig

Board geometry, rider population, obstacles and simultaneous collision/outcome policies.

  • cols: number — Board width in cells, a whole number 1 or more; columns times rows is at most 1,000,000.
  • rows: number — Board height in cells, a whole number 1 or more; columns times rows is at most 1,000,000.
  • riders: RiderDefinition[] — The riders in authored order, which also settles ties under headOn: 'first-wins'; at least one, with unique ids.
  • obstacles?: Cell[] — Solid cells present from the first step (pillars, walls).
  • wrap?: boolean — Leaving one edge enters the opposite edge. Default false: the edge is a wall.
  • headOn?: "all-lose" | "first-wins" — Two riders entering one cell: both crash (default) or the earlier authored rider keeps it.
  • crossing?: "all-lose" | "allow" — Two adjacent riders swapping cells: both crash (default) or pass through each other.
  • goal?: "last-rider" | "survive" — Last rider standing (default) or every rider alive after ticksToWin steps wins.
  • ticksToWin?: number — Steps every survivor must outlast for goal: 'survive', a whole number 1 or more. Default 100.
  • derez?: boolean — A crashed rider's whole trail is removed (the arcade derez). Default false: the wall stays.
  • turnQueue?: number — How many turns a rider may buffer ahead of the next step. Default 2; minimum 1.

LightCycleGridView

interface LightCycleGridView

Read-only cell view for bots and renderers: -2 obstacle, -1 free, otherwise the owning rider index.

  • cols: number — Board width in cells.
  • rows: number — Board height in cells.
  • wrap: boolean — Whether leaving one edge enters the opposite edge.
  • owners: Int32Array — One entry per cell, row by row from the top (index is y * cols + x): -2 obstacle, -1 free, otherwise the index of the owning rider in authored order.

LightCycleLayout

type LightCycleLayout = "none" | "pillars" | "cross" | "ring" | "scatter"

Obstacle layouts the generator knows. none is the open grid of the arcade game.

LightCycleMatchConfig

interface LightCycleMatchConfig

A best-of-N match of rounds on one arena, with humans and seeded bots.

  • arena: LightCycleConfig — The board, riders, obstacles and policies; every round is played on a fresh copy.
  • humans?: string[] — Riders steered by input/turn. Every other rider is a bot. Default: the first rider.
  • difficulty?: BotDifficulty | Partial<BotProfile> — Strength of every bot without its own entry in bots. Default normal.
  • bots?: Record<string, BotDifficulty | Partial<BotProfile>> — Per-rider bot strength.
  • roundsToWin?: number — Round wins needed to take the match (best of 2N-1 in a duel). Default 3.
  • tickRate?: number — Simulation steps per second at the start of each round. Default 12.
  • speedUp?: LightCycleSpeedUp — Optional in-round acceleration.
  • readyTime?: number — Seconds of READY countdown before a round starts moving. Default 1.5.
  • roundEnd?: "humans-out" | "last-rider" — When the round ends. humans-out (default with humans): as soon as every human has crashed, the survivors take the round. last-rider: play until one rider (or none) is left.
  • boostSpeed?: number — Human speed while boosting and while cruising, in (0, 1] cells per step. Default 1 and 1 (no boost).
  • cruiseSpeed?: number — Human speed while not boosting, in (0, 1] cells per step; see boostSpeed.
  • tickBudget?: number — Cell visits shared by all bots in one step (each bot also keeps its own profile budget). Search depth drops before a frame stalls when many strong bots share a large board. Default 6,000,000.
  • seed?: number — Seed for every bot's decisions. Default 1.

LightCycleMatchEvents

interface LightCycleMatchEvents

What changed in one update(dt).

  • steps: number — Simulation steps taken by this update.
  • crashed: string[] — Ids of the riders that crashed during this update.
  • roundOver: boolean — Whether a round ended during this update.
  • matchOver: boolean — Whether the match ended during this update.

LightCycleMatchPhase

type LightCycleMatchPhase = "ready" | "countdown" | "playing" | "round-over" | "match-over"

Match phase: waiting for start, READY countdown, moving, a round result, or the match result.

LightCycleObstacleOptions

interface LightCycleObstacleOptions

Options for lightCycleObstacles.

  • seed?: number — Seed for scatter.
  • density?: number — Fraction of cells covered by scatter blocks, 0..0.3. Default 0.04.
  • starts?: readonly RiderDefinition[] — Cells kept free, with a clear lane clearance cells ahead of each start.
  • clearance?: number — Cells of clear lane kept ahead of each start, a whole number 0 to 1000. Default 4.

LightCyclePresetOptions

interface LightCyclePresetOptions extends Partial<LightCycleConfig>

Options for lightCyclePreset: any config field, plus generated population and layout.

  • riderCount?: number — Rider count when riders is not given.
  • ids?: readonly string[] — Rider ids when riders is not given; the first is conventionally the player.
  • layout?: LightCycleLayout — Obstacle layout when obstacles is not given.
  • seed?: number — Seed for a scatter layout.

LightCycleSpeedUp

interface LightCycleSpeedUp

Rate growth during a round: every every steps the step rate rises by by, up to max.

  • every: number — Steps between rate rises, a whole number 1 or more.
  • by: number — Steps per second added at each rise, 0 to 240.
  • max: number — Highest rate in steps per second, from the match's tickRate up to 240.

LightCycleState

interface LightCycleState

Serializable simultaneous trail state, including winners and draws.

  • riders: RiderState[] — Every rider's state, in authored order.
  • ticks: number — Steps taken so far.
  • status: "playing" | "won" | "draw" — 'playing', 'won' (see winners) or 'draw' (every rider crashed).
  • winners: string[] — Ids of the winning riders once status is 'won'; empty otherwise.

LightCycleStepEvents

interface LightCycleStepEvents

What a single step() changed; useful for sound, particles and scoring.

  • moved: string[] — Ids of the riders that made a move this step, including any that crashed making it.
  • crashed: string[] — Ids of the riders that crashed this step.

RiderDefinition

interface RiderDefinition

One uniquely named starting cell and heading; the rider population is authored data.

  • id: string — Unique nonempty rider name, used by every input and result call.
  • at: Cell — Starting cell: x is the column from the left and y the row from the top, both counted from 0; distinct from every other start and obstacle.
  • direction: Direction — Starting heading: 'up', 'right', 'down' or 'left'.
  • speed?: number — Cells per simulation step, in (0, 1]; default 1. A rider at 0.5 moves on every second step, so a slower cycle and a boosted one share the same simultaneous resolution. setSpeed changes it live.

RiderState

interface RiderState

Current rider position, heading, buffered turns, trail and survival flag.

  • id: string — The rider's name, as in its definition.
  • at: Cell — The head's cell: x is the column from the left and y the row from the top, both counted from 0.
  • direction: Direction — The current heading, as of the last move.
  • speed: number — Cells per step, above 0 and at most 1; below 1 the rider moves on only some steps.
  • progress: number — Fractional move progress; the rider moves when it reaches 1.
  • alive: boolean — Whether the rider is still riding; false once it has crashed.
  • queue: Direction[] — Turns buffered in press order; each step a moving rider takes at most one.
  • trail: Cell[] — Cells behind the head, oldest first. The head (at) is also solid.
  • crashedAt: Cell | null — The cell the rider tried to enter when it crashed.
  • crashTick: number | null — Index of the step on which it crashed, counting the first step as 0; null while it is alive.