An ASCII endless runner that plays in a side pane while Claude is working

A Claude Code mod that plays an ASCII endless runner in a side pane while Claude is working.
RAIL RUNNER * 12 340m
| |---| .-----. |---| |
| * | [][] | * :
| |---| |o o| |---| :
| '=====' |
| H===H |---| |
| O :
| /|\ :
| |---| / \ |---| |
A stick-figure runner dodges trains on three tracks, jumps barriers (H===H) and collects coins (*). The track is generated from a fixed formula, so it never ends and always leaves an open lane. An autopilot steers the runner.
/rail-runner opens the pane by hand.The pane docks beside the transcript when the terminal is in fullscreen and at least 110 columns wide; otherwise it shows above the prompt.
git clone https://github.com/tylergraydev/rail-runner
claude --plugin-dir ./rail-runner
Check it with claude plugin validate ./rail-runner.
.claude-plugin/plugin.json: manifesthooks/hooks.json: lists the hooks modulehooks/register.tsx: the mod (world generation, autopilot, drawing, hooks)types/index.d.ts: the mod's $.state contracthooks/register.tsx 236 lines1import { atom, read, update } from 'claude-code'
2import type { Register, Timer, EngineInterface, RenderInput } from 'claude-code'
3
4import type { Run } from '../types'
5import { BENCH, fillSlot, inSlot, slotOf } from './bench'
6
7const PANE = 'rail-runner'
8const TITLE = 'Rail Runner'
9const TICK_MS = 110
10const LANES = 3
11const SEG = 9 // world rows per track segment
12const LOOKAHEAD = 7
13
14const run = atom({ plugin: 'rail-runner', key: 'run' } as const, {
15 tick: 0,
16 lane: 1,
17 coins: 0,
18 jumpUntil: -1,
19 isRunning: false,
20})
21
22// ---------- the world: a pure function of (row, lane) ----------
23
24const hash = (a: number, b: number): number => {
25 let x = (a * 374761393 + b * 668265263) | 0
26 x = Math.imul(x ^ (x >>> 13), 1274126177)
27 x ^= x >>> 16
28 return (x >>> 0) / 4294967296
29}
30
31const hasTrainInSegment = (s: number, lane: number): boolean => {
32 if (s < 2) return false
33 const wants = [0, 1, 2].map(l => hash(s, l) < 0.42)
34 if (wants.every(Boolean)) wants[s % LANES] = false // always leave a way through
35 return wants[lane] === true
36}
37
38// The train's rows inside its segment: [start, start + 4]; start is its front.
39const trainStart = (s: number, lane: number): number => s * SEG + Math.floor(hash(s, lane + 7) * 3)
40
41type Cell = 'empty' | 'train-front' | 'train-lights' | 'train-body' | 'train-back' | 'barrier' | 'coin'
42
43const cellAt = (row: number, lane: number): Cell => {
44 const s = Math.floor(row / SEG)
45 if (hasTrainInSegment(s, lane)) {
46 const off = row - trainStart(s, lane)
47 if (off === 0) return 'train-front'
48 if (off === 1) return 'train-lights'
49 if (off > 1 && off < 4) return 'train-body'
50 if (off === 4) return 'train-back'
51 return 'empty'
52 }
53 const off = row - s * SEG
54 if (s >= 2 && off === 7 && hash(s, lane + 20) < 0.35) return 'barrier'
55 if (hash(s, lane + 60) < 0.5 && off >= 1 && off <= 5 && off % 2 === 1) return 'coin'
56 return 'empty'
57}
58
59const isTrain = (row: number, lane: number) => cellAt(row, lane).startsWith('train')
60
61// ---------- the runner's autopilot ----------
62
63const firstTrain = (from: number, lane: number): number => {
64 for (let k = 0; k <= LOOKAHEAD; k++) if (isTrain(from + k, lane)) return k
65 return Infinity
66}
67
68const step = (r: Run): Run => {
69 const tick = r.tick + 1
70 let { lane, coins, jumpUntil } = r
71
72 if (firstTrain(tick, lane) <= LOOKAHEAD) {
73 let best = lane
74 let bestDist = firstTrain(tick, lane)
75 for (let l = 0; l < LANES; l++) {
76 const d = firstTrain(tick, l) - Math.abs(l - lane) * 0.1 // prefer near lanes
77 if (d > bestDist) {
78 best = l
79 bestDist = d
80 }
81 }
82 lane = best
83 }
84
85 const isAirborne = tick < jumpUntil
86 if (!isAirborne && (cellAt(tick + 1, lane) === 'barrier' || isTrain(tick, lane))) {
87 jumpUntil = tick + 4
88 }
89 if (cellAt(tick, lane) === 'coin') coins += 1
90
91 return { ...r, tick, lane, coins, jumpUntil }
92}
93
94// ---------- drawing ----------
95
96type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
97
98const center = (s: string, w: number): string => {
99 const left = Math.floor((w - s.length) / 2)
100 return ' '.repeat(Math.max(0, left)) + s + ' '.repeat(Math.max(0, w - s.length - left))
101}
102
103const trackCell = (row: number, lane: number, w: number): Seg => {
104 const inner = w - 2
105 switch (cellAt(row, lane)) {
106 case 'train-back':
107 return { text: '.' + '-'.repeat(inner) + '.', color: 'cyan' }
108 case 'train-body':
109 return { text: '|' + center('[]'.repeat(Math.max(1, Math.floor(inner / 3))), inner) + '|', color: 'cyan' }
110 case 'train-lights':
111 return { text: '|' + center('o o'.slice(0, Math.max(3, inner)), inner) + '|', color: 'cyan', bold: true }
112 case 'train-front':
113 return { text: "'" + '='.repeat(inner) + "'", color: 'cyan' }
114 case 'barrier':
115 return { text: 'H' + '='.repeat(inner) + 'H', color: 'yellow' }
116 case 'coin':
117 return { text: center('*', w), color: 'yellow', bold: true }
118 default: {
119 const tie = row % 2 === 0 ? '-' : ' '
120 return { text: ' |' + tie.repeat(Math.max(0, w - 4)) + '| ', dim: true }
121 }
122 }
123}
124
125const runnerSprite = (r: Run, row: number): string | null => {
126 const isAirborne = r.tick < r.jumpUntil
127 const base = r.tick + (isAirborne ? 1 : 0)
128 const off = row - base
129 if (off === 2) return 'O'
130 if (off === 1) return isAirborne ? '\\|/' : r.tick % 2 ? '/|\\' : '-|\\'
131 if (off === 0) return isAirborne ? '/ \\' : r.tick % 2 ? '/ \\' : ' |\\'
132 if (isAirborne && off === -1) return '~~~'
133 return null
134}
135
136const drawLine = (r: Run, row: number, w: number): Seg[] => {
137 const wall = row % 4 < 2 ? '|' : ':'
138 const out: Seg[] = [{ text: wall, dim: true }]
139 for (let lane = 0; lane < LANES; lane++) {
140 const sprite = lane === r.lane ? runnerSprite(r, row) : null
141 if (sprite) {
142 out.push({ text: center(sprite, w), color: sprite === '~~~' ? undefined : 'magenta', dim: sprite === '~~~', bold: true })
143 } else {
144 out.push(trackCell(row, lane, w))
145 }
146 out.push({ text: lane < LANES - 1 ? ' ' : wall, dim: true })
147 }
148 return out
149}
150
151// ---------- hooks ----------
152
153export const register: Register = on => {
154 let timer: Timer | undefined
155
156 on('session.start', async ($, e, next) => {
157 await $.command.register({
158 name: 'rail-runner',
159 description: 'Open the Rail Runner pane (it plays while Claude works)',
160 })
161 // One timer for the whole session (work that outlives a dispatch starts
162 // here); it only advances the run while a turn is going.
163 timer?.cancel()
164 timer = $.clock.every(TICK_MS, () => {
165 void (async () => {
166 if ((await read($, run)).isRunning) await update($, run, step)
167 })()
168 })
169 return next(e)
170 })
171
172 on('command.run', { command: 'rail-runner' }, async $ => {
173 await $.ui.open({ id: PANE, title: TITLE })
174 return { text: 'Rail Runner opened. It runs while Claude is working.' }
175 })
176
177 // Opening from the prompt the person submitted counts as their own act,
178 // so the pane seats at any width.
179 on('prompt.submit', async ($, e, next) => {
180 void $.ui.open({ id: PANE, title: TITLE })
181 return next(e)
182 })
183
184 on('turn.start', async ($, e, next) => {
185 await update($, run, r => ({ ...r, isRunning: true }))
186 return next(e)
187 })
188
189 on('turn.complete', async ($, e, next) => {
190 await update($, run, r => ({ ...r, isRunning: false }))
191 return next(e)
192 })
193
194 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
195
196 // Inside the workbench: fill this pane's slot in its frame.
197 on('ui.render', { component: 'Pane', requestId: BENCH }, async ($, e, next) => {
198 const frame = await next(e)
199 const slot = slotOf(frame, PANE)
200 return slot ? fillSlot(frame, PANE, await drawPane($, inSlot(e, slot.columns))) : frame
201 })
202}
203
204/** The pane's drawing, in its own pane or in a workbench slot. */
205async function drawPane($: EngineInterface, e: RenderInput<'Pane'>) {
206 const { Box, Text } = $.ui.resolve(e)
207 const r = await read($, run)
208
209 const cols = e.props.bodyColumns || e.viewport?.columns || 40
210 const w = Math.max(5, Math.min(11, Math.floor((cols - 4) / LANES)))
211 const height = Math.max(10, Math.min(40, (e.viewport?.rows ?? 30) - 8))
212
213 const lines: Seg[][] = []
214 for (let i = 0; i < height; i++) lines.push(drawLine(r, r.tick + (height - 3) - i, w))
215
216 return (
217 <Box flexDirection="column">
218 <Box>
219 <Text bold color="magenta">RAIL RUNNER </Text>
220 <Text color="yellow">* {r.coins} </Text>
221 <Text dimColor>{r.tick}m</Text>
222 </Box>
223 {lines.map((segs, i) => (
224 <Box key={`l${i}`}>
225 {segs.map((s, j) => (
226 <Text key={`s${j}`} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate">
227 {s.text}
228 </Text>
229 ))}
230 </Box>
231 ))}
232 <Text dimColor>{r.isRunning ? 'Claude is working...' : 'Waiting for Claude.'}</Text>
233 </Box>
234 )
235}
236hooks/bench.ts 41 lines1import type { RenderElement } from 'claude-code'
2
3// Drawing inside the workbench mod (C:\code\cc-mods\workbench): it draws a
4// frame with an empty Box keyed `slot-<pane id>` for each pane it shows, and
5// this mod hooks the workbench pane's ui.render, takes that frame from
6// next(e) and fills its own slot. The same file sits in each hosted mod.
7
8/** The workbench's pane id. */
9export const BENCH = 'workbench'
10
11type Node = { type: string; props?: { key?: unknown; width?: unknown }; children?: unknown[] }
12
13const isNode = (value: unknown): value is Node =>
14 typeof value === 'object' && value !== null && typeof (value as Node).type === 'string'
15
16/** The columns the workbench gave pane `id`'s slot; undefined while it does not show it. */
17export function slotOf(tree: unknown, id: string): { columns: number } | undefined {
18 if (!isNode(tree)) return undefined
19 if (tree.props?.key === `slot-${id}`) return { columns: typeof tree.props.width === 'number' ? tree.props.width : 40 }
20 for (const child of tree.children ?? []) {
21 const found = slotOf(child, id)
22 if (found) return found
23 }
24 return undefined
25}
26
27/** The frame with pane `id`'s slot holding `body` in place of the placeholder. */
28export function fillSlot(tree: RenderElement, id: string, body: RenderElement): RenderElement {
29 const fill = (node: unknown): unknown => {
30 if (!isNode(node)) return node
31 if (node.props?.key === `slot-${id}`) return { ...node, children: [body] }
32 return node.children ? { ...node, children: node.children.map(fill) } : node
33 }
34 return fill(tree) as RenderElement
35}
36
37/** A pane event narrowed to a slot's width, for a draw function written for the pane. */
38export function inSlot<E extends { props: { bodyColumns: number }; viewport?: { columns: number } }>(e: E, columns: number): E {
39 return { ...e, props: { ...e.props, bodyColumns: columns }, ...(e.viewport ? { viewport: { ...e.viewport, columns } } : {}) }
40}
41types/index.d.ts 14 lines1export type Run = {
2 tick: number
3 lane: number
4 coins: number
5 jumpUntil: number
6 isRunning: boolean
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'rail-runner': { run: Run }
12 }
13}
14