SLOPSHOPPER

rail-runner

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

newpanecommandprompttimer
v0.1.0no licenseupdated 2026-10-07tylergraydev/rail-runner
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rail-runner
│ ┃ Rail Runner ✕ › fix the failing auth test and add an audit log call │ ┃ RAIL RUNNER * 0 0m │ ┃ :.---------. | | | [][][] |: ⏺ Read(src/auth.ts) │ ┃ :| [][][] | |-------| | [][][] |: ⎿ Read 6 lines │ ┃ || [][][] | | | | o o || ⏺ Update(src/auth.ts) │ ┃ || o o | |-------| '========='| ⎿ Added 2 lines, removed 1 line │ ┃ :'=========' | | | | : ⏺ Bash(bun test) │ ┃ : |-------| |-------| |-------| : ⎿ 3 pass, 1 fail │ ┃ | | | | | | | | │ ┃ | |-------| |-------| |-------| | ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ :.---------. * | | : │ ┃ :| [][][] | |-------| |-------| : ✻ Worked for 42s · done 4:20 PM │ ┃ || [][][] | * | | | │ ┃ || o o | |-------| |-------| | › /rail-runner │ ┃ :'=========' * | | : ⎿ rail-runner: Rail Runner opened. It runs while Claude is working │ ┃ : |-------| |-------| |-------| : │ ┃ | | | | | | | | │ ┃ | |-------| |-------| |-------| | │ ┃ : | | | | | | : │ ┃ : |-------| * * : │ ┃ | | | | | | | | │ ┃ | |-------| * * | │ ┃ : | | | | | | : │ ┃ : |-------| * * : │ ┃ | | | | | | | | │ ┃ | |-------| |-------| |-------| | ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Rail Runner
RAIL RUNNER * 0 0m :.---------. | | | [][][] |: :| [][][] | |-------| | [][][] |: || [][][] | | | | o o || || o o | |-------| '========='| :'=========' | | | | : : |-------| |-------| |-------| : | | | | | | | | | |-------| |-------| |-------| | :.---------. * | | : :| [][][] | |-------| |-------| : || [][][] | * | | | || o o | |-------| |-------| | :'=========' * | | : : |-------| |-------| |-------| : | | | | | | | | | |-------| |-------| |-------| | : | | | | | | : : |-------| * * : | | | | | | | | | |-------| * * | : | | | | | | : : |-------| * * : | | | | | | | | | |-------| |-------| |-------| | : | | | | | | : : |-------| |-------| |-------| : | | | | | * | | |-------| |-------| |-------| | : | | | | * : : |-------| O |-------| : | | | -|\ * | | |-------| |\ |-------| | | | | | | | | | | |-------| |-------| |-------| | Waiting for Claude.
README

Rail Runner

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.

Behavior

  • Submitting a prompt opens the Rail Runner pane.
  • The run animates while Claude is working (about 9 frames a second) and pauses when the turn ends. Coins and distance carry over between turns.
  • /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.

Install

git clone https://github.com/tylergraydev/rail-runner
claude --plugin-dir ./rail-runner

Check it with claude plugin validate ./rail-runner.

Layout

  • .claude-plugin/plugin.json: manifest
  • hooks/hooks.json: lists the hooks module
  • hooks/register.tsx: the mod (world generation, autopilot, drawing, hooks)
  • types/index.d.ts: the mod's $.state contract
Source 3 files
hooks/register.tsx 236 lines
1import { 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}
236
hooks/bench.ts 41 lines
1import 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}
41
types/index.d.ts 14 lines
1export 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