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 recentstep().constructor(cfg: LightCycleConfig)get state(): LightCycleState— A deep copy of the whole state (serializable). Preferrider()/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 theturnQueuecapacity. 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 nextstep()(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, forrestore.restore(s: LightCycleState)— Loads a state fromsnapshotand 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):
- Moves into solid cells lose; simultaneous head-ons are scored as a draw and avoided.
- While any opponent can reach the bot, it searches
lookaheadjoint 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. - 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).
- 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.budgetcaps 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 byinput,turnandboost; every other rider is a bot.phase: LightCycleMatchPhase— Where the match stands: waiting tostart, counting down, playing, a round result, or the match result.paused: boolean— Whether the clock is frozen; set bypauseand cleared bystart.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 viaownerAt,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 forstart().restartRound()— Replay the current round from its start (scores unchanged), waiting forstart().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 unlessboostSpeedis 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 underheadOn: '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 afterticksToWinsteps wins.ticksToWin?: number— Steps every survivor must outlast forgoal: '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 isy * 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 byinput/turn. Every other rider is a bot. Default: the first rider.difficulty?: BotDifficulty | Partial<BotProfile>— Strength of every bot without its own entry inbots. 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; seeboostSpeed.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 forscatter.density?: number— Fraction of cells covered byscatterblocks, 0..0.3. Default 0.04.starts?: readonly RiderDefinition[]— Cells kept free, with a clear laneclearancecells 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 whenridersis not given.ids?: readonly string[]— Rider ids whenridersis not given; the first is conventionally the player.layout?: LightCycleLayout— Obstacle layout whenobstaclesis not given.seed?: number— Seed for ascatterlayout.
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'stickRateup 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'(seewinners) or'draw'(every rider crashed).winners: string[]— Ids of the winning riders oncestatusis'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:xis the column from the left andythe 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.setSpeedchanges 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:xis the column from the left andythe 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.


