SLOPSHOPPER

respawn

Auto-continue after a turn dies on a network or API error: a pixel-art CONTINUE? countdown with backoff and 3 lives per hour.

newbandcommandtoaststatusprompt
v0.1.0MITupdated 2026-10-03pourya7/claude-code-mods/respawn
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · respawn
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 › /respawn ⎿ respawn: READY! ♥♥♥ 3/3 lives this hour, next wait 10s. Watching for turns that die on an API error. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README
█▀█ █▀▀ █▀ █▀█ ▄▀█ █ █ █ █▄ █
█▀▄ ██▄ ▄█ █▀▀ █▀█ ▀▄▀▄▀ █ ▀█   CONTINUE? 9

respawn — continue after a network death

The problem: a long, unattended turn dies on an API or connection error, and nothing happens until you come back and type continue. The study behind this library counted ~120 API connection errors, each followed by a hand-typed "continue" or "try again".

respawn watches for a turn that ends with reason: 'error' in the main loop, shows an orange arcade CONTINUE? countdown, and submits continue for you when it reaches zero. It backs off on repeated failures and has a limited number of lives, so it cannot loop forever.

Install

/plugin marketplace add pourya7/claude-code-mods
/plugin install respawn@claude-code-mods

How it behaves

  • Trigger. Only a main-loop turn that ends with reason: 'error' (an API error that used up its retries) starts a countdown. Answers, interrupts (aborted), refusals and subagent turns never do.
  • Backoff. The countdown waits 10s, then 30s, 60s, 120s and 300s on each failure in a row. It stays at 300s after that.
  • Lives. Each automatic continue costs one life. You get 3 per rolling hour by default (♥♥♡). With no lives left, the band shows GAME OVER and respawn does nothing more until you send a prompt yourself. GAME OVER holds even after the hour gives lives back, and a scheduled or background prompt does not end it. While it shows, the band counts down the minutes to the next life and refills the hearts as they return.
  • Cancel. Any of these stops the countdown and resets the backoff to 10s:
  • typing anything in the prompt box
  • sending a prompt
  • a successful turn (this does not end GAME OVER, only your own prompt does)
  • [GAME OVER]
  • /respawn off
  • Continue now. [INSERT COIN] skips the wait and spends a life. Pressing it twice quickly still sends one continue.
  • Quiet otherwise. The band appears only while a countdown or GAME OVER is showing. It steps aside for a survey and for a subagent's transcript view.

Commands

CommandWhat it does
/respawnShows the state: READY!, CONTINUE? n, GAME OVER or OFF, the lives left this hour and the next wait.
/respawn offCancels any countdown and switches respawn off for the rest of the session.
/respawn onSwitches it back on.

The band's buttons have hotkeys. Once the band has focus (ctrl+x tab or a click), press c for INSERT COIN, x for GAME OVER, or o for OK.

Configuration (userConfig)

FieldTypeDefaultMeaning
livesnumber3How many automatic continues respawn may spend in any rolling hour. 0 means it never continues by itself, but still shows GAME OVER when a turn dies.
promptstringcontinueThe text respawn submits when the countdown ends. Empty means continue.

You can change these in the /config menu, or under pluginConfigs.respawn in your settings.

The UI

The band above the prompt during a countdown. The second failure in a row has a 30s wait, with 27s left:

▶ CONTINUE? ♥♥♡ 1UP RESPAWN
▄██▄██▄  ▀▀█ ▀▀█  TURN DIED ON AN API ERROR
▀█████▀  █▀▀  ▄▀  SENDING "CONTINUE" IN 27S
  ▀█▀    ███  █   TRY 2 · TYPE ANYTHING TO CANCEL
[ INSERT COIN ] [ GAME OVER ]

After three continues inside the same hour:

▶ GAME OVER ♡♡♡ 1UP RESPAWN
▄██▄██▄  OUT OF LIVES THIS HOUR
▀█████▀  NEXT LIFE IN 59 MIN
  ▀█▀    TYPE ANYTHING TO PLAY ON
[ OK ]

These captures are plain text. In the terminal, the heart is red with a pink shine. The digits are half-block pixels: orange, with a yellow top row and a brown drop shadow. They use the PICO-8 palette.

The status line under the prompt shows the same state in under 40 columns, for example RESPAWN CONTINUE? 27 ♥♥♡ or RESPAWN GAME OVER ♡♡♡. VS Code and claude -p have no band, so the status line and /respawn are what you see there.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData leaving the machine
None. No $.http.None. No $.process.None. No $.fs. The state is kept only for the session, in $.state. Nothing goes to $.store.No. respawn makes no $.model call of its own.Yes. When a countdown ends or you press INSERT COIN, it calls $.prompt.submit with the configured prompt (continue), as your own words. This is limited by lives per hour, and /respawn off switches it off.Only that continue prompt, which goes to your own model as an ordinary turn.

Limits

  • Hot reload. A reload, including the one a userConfig change causes, cancels the mod's timers. respawn picks a running countdown up again when it reloads, and submits at once if it fell due while it was reloading.
  • Interrupted turns. A turn you interrupt (aborted) is never retried, even if the network was the cause.

Development

claude plugin validate respawn
claude plugin test respawn

Pure logic lives in hooks/logic.ts (backoff, lives, transitions and text) and hooks/pixels.ts (the half-block sprite renderer and the digit font). hooks/register.tsx connects them to the engine. The tests use mock.clock and mount the band on both terminal and desktop.

Source 4 files
hooks/register.tsx 270 lines
1import { atom, read, update } from 'claude-code'
2import type { Register, Timer } from 'claude-code'
3
4import type { RespawnRun } from '../types'
5import {
6  IDLE_RUN,
7  PLUGIN,
8  cancelRun,
9  continuePrompt,
10  describeRun,
11  hearts,
12  isOwnPrompt,
13  isUserPrompt,
14  livesLeft,
15  livesOption,
16  minutesToNextLife,
17  refreshGameOver,
18  spendLife,
19  startCountdown,
20  statusLine,
21  tickRun,
22} from './logic'
23import { BROKEN_HEART, HEART, PALETTE, besides, digitsGrid, pixelRows } from './pixels'
24import type { Run } from './pixels'
25
26const run = atom({ plugin: 'respawn', key: 'run' } as const, IDLE_RUN)
27
28type Dollar = Parameters<Parameters<Parameters<Register>[0]>[1]>[0]
29type Settings = { lives: number; promptText: string }
30
31// Timers cannot live in $.state, and a reload cancels them along with this
32// variable; session.start (raised on a reload too) arms them again from `run`.
33let ticker: Timer | undefined
34
35const GAME_OVER_TICK_MS = 60_000
36
37function stopTicker(): void {
38  ticker?.cancel()
39  ticker = undefined
40}
41
42async function showStatus($: Dollar, settings: Settings, current: RespawnRun): Promise<void> {
43  $.ui.status(statusLine(current, settings.lives, await $.clock.now()))
44}
45
46/** Cancels a countdown and resets the backoff; a user prompt also ends GAME OVER. */
47async function cancel($: Dollar, settings: Settings, isUser: boolean): Promise<void> {
48  const current = await read($, run)
49  const isQuiet = current.phase === 'idle' && current.attempt === 0
50  if (isQuiet || (current.phase === 'gameover' && !isUser)) return
51  stopTicker()
52  const next = await update($, run, value => (value.phase === 'gameover' && !isUser ? value : cancelRun(value)))
53  await showStatus($, settings, next)
54}
55
56/** The countdown ran out or INSERT COIN was pressed: spend a life and continue. */
57async function insertCoin($: Dollar, settings: Settings): Promise<void> {
58  const now = await $.clock.now()
59  // Check and spend in one update, so two presses close together spend one life.
60  let isMine = false as boolean
61  const next = await update($, run, value => {
62    // update() runs this again on a version miss: the last call decides.
63    isMine = value.phase === 'countdown' && !value.isOff
64    return isMine ? spendLife(value, now) : value
65  })
66  if (!isMine) return
67  stopTicker()
68  await showStatus($, settings, next)
69  try {
70    await $.prompt.submit({ text: settings.promptText, asUser: true })
71  } catch {
72    $.ui.toast('RESPAWN: could not submit the continue prompt')
73  }
74}
75
76async function tick($: Dollar, settings: Settings): Promise<void> {
77  const current = await read($, run)
78  if (current.phase !== 'countdown') return stopTicker()
79  const now = await $.clock.now()
80  if (now >= current.dueAt) return insertCoin($, settings)
81  await showStatus($, settings, await update($, run, value => tickRun(value, now)))
82}
83
84/** GAME OVER: once a minute, move the "next life" minutes and the hearts. */
85async function tickGameOver($: Dollar, settings: Settings): Promise<void> {
86  const current = await read($, run)
87  if (current.phase !== 'gameover') return stopTicker()
88  const now = await $.clock.now()
89  const next = await update($, run, value => (value.phase === 'gameover' ? refreshGameOver(value, now) : value))
90  await showStatus($, settings, next)
91  if (next.spentAt.length === 0) stopTicker()
92}
93
94/** Starts the ticker that fits the phase in `run`, or none. */
95function arm($: Dollar, settings: Settings, current: RespawnRun): void {
96  stopTicker()
97  if (current.isOff) return
98  if (current.phase === 'countdown') {
99    ticker = $.clock.every(1000, () => void tick($, settings).catch(stopTicker))
100  } else if (current.phase === 'gameover' && current.spentAt.length > 0) {
101    ticker = $.clock.every(GAME_OVER_TICK_MS, () => void tickGameOver($, settings).catch(stopTicker))
102  }
103}
104
105async function startRespawn($: Dollar, settings: Settings): Promise<void> {
106  const now = await $.clock.now()
107  const before = await read($, run)
108  const next = await update($, run, value => (value.isOff ? value : startCountdown(value, now, settings.lives)))
109  await showStatus($, settings, next)
110  // A turn dying again in GAME OVER keeps the minute ticker already running.
111  if (before.phase === 'gameover' && next.phase === 'gameover' && ticker !== undefined) return
112  arm($, settings, next)
113}
114
115async function switchOff($: Dollar): Promise<void> {
116  stopTicker()
117  await update($, run, value => ({ ...cancelRun(value), isOff: true }))
118  $.ui.status(undefined)
119}
120
121export const register: Register = (on, options) => {
122  const settings: Settings = { lives: livesOption(options.lives), promptText: continuePrompt(options.prompt) }
123  const { lives, promptText } = settings
124
125  on('session.start', async ($, e, next) => {
126    try {
127      await $.command.register({
128        name: PLUGIN,
129        description: 'Show the auto-continue state; /respawn off or /respawn on',
130        argumentHint: '[off|on]',
131        immediate: true,
132      })
133    } catch {
134      $.ui.toast('RESPAWN: /respawn could not be registered')
135    }
136
137    // A reload (a code change or a userConfig change) cancelled our timers:
138    // pick the countdown or the GAME OVER minutes up where `run` left them.
139    const current = await read($, run)
140    if (current.phase === 'countdown' && !current.isOff) {
141      const now = await $.clock.now()
142      await showStatus($, settings, await update($, run, value => tickRun(value, now)))
143    }
144    arm($, settings, current)
145
146    return next(e)
147  })
148
149  on('turn.complete', async ($, e, next) => {
150    const result = await next(e)
151    if (e.agentId !== undefined) return result
152    if (e.reason === 'error') await startRespawn($, settings)
153    if (e.reason === 'answer') await cancel($, settings, false)
154
155    return result
156  })
157
158  on('prompt.submit', async ($, e, next) => {
159    if (!isOwnPrompt(e.origin)) await cancel($, settings, isUserPrompt(e.origin))
160
161    return next(e)
162  })
163
164  on('prompt.edit', async ($, e, next) => {
165    const isChange = e.inputText !== '' || e.start !== e.end
166    if (isChange) await cancel($, settings, true)
167
168    return next(e)
169  })
170
171  on('command.run', { command: 'respawn' }, async ($, e) => {
172    const word = e.args.trim().toLowerCase()
173    if (word === 'off') await switchOff($)
174    if (word === 'on') await update($, run, value => ({ ...value, isOff: false }))
175    const now = await $.clock.now()
176    const text = describeRun(await read($, run), lives, now)
177    const isKnown = word === '' || word === 'off' || word === 'on'
178
179    return { text: isKnown ? text : `${text}\nUsage: /respawn [off|on]` }
180  })
181
182  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
183    if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
184    const current = await read($, run)
185    if (current.isOff || current.phase === 'idle') return next(e)
186
187    const { Box, Button, Text } = $.ui.resolve(e)
188    const now = await $.clock.now()
189    const left = livesLeft(current.spentAt, lives, now)
190    const isOver = current.phase === 'gameover'
191
192    const sprite = isOver ? [...BROKEN_HEART] : besides([HEART, digitsGrid(current.secondsLeft)], 2)
193    const pixels = pixelRows(sprite).map(runs => (
194      <Box key="digit-row" flexDirection="row">
195        {runs.map((cell: Run) => (
196          <Text color={cell.color} backgroundColor={cell.backgroundColor}>
197            {cell.text}
198          </Text>
199        ))}
200      </Box>
201    ))
202
203    const minutesToLife = minutesToNextLife(current.spentAt, now)
204
205    const lines = isOver
206      ? [
207          <Text color={PALETTE.w}>{left === 0 ? 'OUT OF LIVES THIS HOUR' : 'LIVES ARE BACK'}</Text>,
208          <Box key="next-life">
209            <Text color={PALETTE.l}>
210              {left === 0 ? `NEXT LIFE IN ${minutesToLife} MIN` : `${left} READY, HELD FOR YOU`}
211            </Text>
212          </Box>,
213          <Text color={PALETTE.l}>TYPE ANYTHING TO PLAY ON</Text>,
214        ]
215      : [
216          <Text color={PALETTE.w}>TURN DIED ON AN API ERROR</Text>,
217          <Box flexDirection="row">
218            <Text color={PALETTE.l}>SENDING "{promptText.toUpperCase()}" IN </Text>
219            <Box key="seconds">
220              <Text color={PALETTE.y} bold>
221                {String(current.secondsLeft)}
222              </Text>
223            </Box>
224            <Text color={PALETTE.l}>S</Text>
225          </Box>,
226          <Text color={PALETTE.l}>TRY {String(current.attempt + 1)} · TYPE ANYTHING TO CANCEL</Text>,
227        ]
228
229    return (
230      <Box flexDirection="column">
231        <Box flexDirection="row">
232          <Box key="title">
233            <Text color={isOver ? PALETTE.r : PALETTE.o} bold>
234              {isOver ? '▶ GAME OVER' : '▶ CONTINUE?'}
235            </Text>
236          </Box>
237          <Text> </Text>
238          <Box key="lives">
239            <Text color={PALETTE.r}>{hearts(left, lives)}</Text>
240          </Box>
241          <Text color={PALETTE.d}> 1UP RESPAWN</Text>
242        </Box>
243        <Box flexDirection="row">
244          <Box flexDirection="column">{pixels}</Box>
245          <Box flexDirection="column" marginLeft={2}>
246            {lines}
247          </Box>
248        </Box>
249        {isOver ? (
250          <Box flexDirection="row">
251            <Button key="dismiss" label="OK" hotkey="o" role="dismiss" onPress={() => cancel($, settings, true)} />
252          </Box>
253        ) : (
254          <Box flexDirection="row">
255            <Button
256              key="insert-coin"
257              label="INSERT COIN"
258              hotkey="c"
259              variant="primary"
260              onPress={() => insertCoin($, settings)}
261            />
262            <Text> </Text>
263            <Button key="game-over" label="GAME OVER" hotkey="x" onPress={() => cancel($, settings, true)} />
264          </Box>
265        )}
266      </Box>
267    )
268  })
269}
270
hooks/logic.ts 110 lines
1import type { PromptOrigin } from 'claude-code'
2
3import type { RespawnRun } from '../types'
4
5export const PLUGIN = 'respawn'
6export const HOUR_MS = 60 * 60 * 1000
7export const BACKOFF_SECONDS: readonly number[] = [10, 30, 60, 120, 300]
8export const DEFAULT_PROMPT = 'continue'
9
10export const IDLE_RUN: RespawnRun = {
11  phase: 'idle',
12  attempt: 0,
13  dueAt: 0,
14  secondsLeft: 0,
15  spentAt: [],
16  minutesToLife: 0,
17  isOff: false,
18}
19
20/** Seconds to wait before the continue of a given step (0 = first). */
21export const backoffSeconds = (attempt: number): number =>
22  BACKOFF_SECONDS[Math.min(Math.max(0, attempt), BACKOFF_SECONDS.length - 1)] ?? 300
23
24/** The spend times still inside the rolling hour. */
25export const recentSpends = (spentAt: readonly number[], now: number): number[] =>
26  spentAt.filter(at => now - at < HOUR_MS)
27
28export const livesLeft = (spentAt: readonly number[], lives: number, now: number): number =>
29  Math.max(0, lives - recentSpends(spentAt, now).length)
30
31export const hearts = (left: number, total: number): string =>
32  '♥'.repeat(Math.max(0, left)) + '♡'.repeat(Math.max(0, total - left))
33
34/** Whole minutes until the oldest life spent this hour comes back; 0 when none is spent. */
35export const minutesToNextLife = (spentAt: readonly number[], now: number): number => {
36  const oldest = recentSpends(spentAt, now)[0]
37
38  return oldest === undefined ? 0 : Math.max(1, Math.ceil((oldest + HOUR_MS - now) / 60_000))
39}
40
41/** GAME OVER holds; only the lives coming back over the hour move. */
42export const refreshGameOver = (run: RespawnRun, now: number): RespawnRun => ({
43  ...run,
44  spentAt: recentSpends(run.spentAt, now),
45  minutesToLife: minutesToNextLife(run.spentAt, now),
46})
47
48/**
49 * A turn died on an error: count down at this step, or GAME OVER when out of
50 * lives. GAME OVER itself holds: only a user prompt (cancelRun) leaves it.
51 */
52export const startCountdown = (run: RespawnRun, now: number, lives: number): RespawnRun => {
53  if (run.phase === 'gameover') return refreshGameOver(run, now)
54  const spentAt = recentSpends(run.spentAt, now)
55  if (livesLeft(spentAt, lives, now) === 0) return refreshGameOver({ ...run, spentAt, phase: 'gameover', secondsLeft: 0 }, now)
56  const seconds = backoffSeconds(run.attempt)
57
58  return { ...run, spentAt, phase: 'countdown', dueAt: now + seconds * 1000, secondsLeft: seconds }
59}
60
61/** A continue was submitted: one life gone, the next error waits longer. */
62export const spendLife = (run: RespawnRun, now: number): RespawnRun => ({
63  ...run,
64  phase: 'idle',
65  attempt: run.attempt + 1,
66  secondsLeft: 0,
67  spentAt: [...recentSpends(run.spentAt, now), now],
68})
69
70/** Success, the user, or `/respawn off`: stop counting and start the backoff over. */
71export const cancelRun = (run: RespawnRun): RespawnRun => ({ ...run, phase: 'idle', attempt: 0, secondsLeft: 0 })
72
73export const tickRun = (run: RespawnRun, now: number): RespawnRun => ({
74  ...run,
75  secondsLeft: Math.max(0, Math.ceil((run.dueAt - now) / 1000)),
76})
77
78export const isOwnPrompt = (origin: PromptOrigin): boolean => origin.kind === 'plugin' && origin.name === PLUGIN
79
80/** The person: typed at the composer, sent from a phone, or the SDK host's own turn. */
81export const isUserPrompt = (origin: PromptOrigin): boolean =>
82  origin.kind === 'composer' || origin.kind === 'bridge' || origin.kind === 'sdk'
83
84export const continuePrompt = (configured: unknown): string =>
85  typeof configured === 'string' && configured.trim() !== '' ? configured : DEFAULT_PROMPT
86
87export const livesOption = (configured: unknown): number =>
88  typeof configured === 'number' && Number.isFinite(configured) ? Math.max(0, Math.floor(configured)) : 3
89
90/** The status line under the prompt (under ~40 columns), or undefined when idle. */
91export const statusLine = (run: RespawnRun, lives: number, now: number): string | undefined => {
92  const life = hearts(livesLeft(run.spentAt, lives, now), lives)
93  if (run.phase === 'countdown') return `RESPAWN CONTINUE? ${run.secondsLeft} ${life}`
94  if (run.phase === 'gameover') return `RESPAWN GAME OVER ${life}`
95
96  return undefined
97}
98
99/** What `/respawn` answers. */
100export const describeRun = (run: RespawnRun, lives: number, now: number): string => {
101  const left = livesLeft(run.spentAt, lives, now)
102  const life = `${hearts(left, lives)} ${left}/${lives} lives this hour`
103  const next = `next wait ${backoffSeconds(run.attempt)}s`
104  if (run.isOff) return `RESPAWN OFF. ${life}. /respawn on to arm it again.`
105  if (run.phase === 'countdown') return `CONTINUE? ${run.secondsLeft} ${life}. /respawn off cancels.`
106  if (run.phase === 'gameover') return `GAME OVER ${life}. Respawn waits for your next prompt.`
107
108  return `READY! ${life}, ${next}. Watching for turns that die on an API error.`
109}
110
hooks/pixels.ts 120 lines
1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9  k: '#000000', // black
10  n: '#1D2B53', // navy
11  m: '#7E2553', // plum
12  g: '#008751', // green
13  b: '#AB5236', // brown
14  d: '#5F574F', // dark grey
15  l: '#C2C3C7', // light grey
16  w: '#FFF1E8', // white
17  r: '#FF004D', // red
18  o: '#FFA300', // orange: respawn's signature
19  y: '#FFEC27', // yellow
20  e: '#00E436', // lime
21  u: '#29ADFF', // blue
22  v: '#83769C', // lavender
23  i: '#FF77A8', // pink
24  c: '#FFCCAA', // peach
25} as const
26
27export type Run = { text: string; color?: string; backgroundColor?: string }
28
29const colorOf = (key: string | undefined): string | undefined =>
30  key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
31
32const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
33  if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
34  if (top) return { text: '▀', color: top }
35  if (bottom) return { text: '▄', color: bottom }
36
37  return { text: ' ' }
38}
39
40const sameStyle = (a: Run, b: Run): boolean => a.color === b.color && a.backgroundColor === b.backgroundColor
41
42/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
43export const pixelRows = (grid: readonly string[]): Run[][] => {
44  const rows: Run[][] = []
45  const width = Math.max(0, ...grid.map(line => line.length))
46  for (let y = 0; y < grid.length; y += 2) {
47    const runs: Run[] = []
48    for (let x = 0; x < width; x += 1) {
49      const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
50      const last = runs[runs.length - 1]
51      if (last && last.text[0] === cell.text && sameStyle(last, cell)) last.text += cell.text
52      else if (last && last.text[0] === ' ' && cell.text === ' ') last.text += ' '
53      else runs.push({ ...cell })
54    }
55    rows.push(runs)
56  }
57
58  return rows
59}
60
61/** Lines sprites up left to right, `gap` transparent columns apart, tops aligned. */
62export const besides = (sprites: readonly (readonly string[])[], gap = 1): string[] => {
63  const height = Math.max(0, ...sprites.map(sprite => sprite.length))
64  const widths = sprites.map(sprite => Math.max(0, ...sprite.map(line => line.length)))
65  const lines: string[] = []
66  for (let y = 0; y < height; y += 1) {
67    const parts = sprites.map((sprite, i) => (sprite[y] ?? '').padEnd(widths[i] ?? 0, '.'))
68    lines.push(parts.join('.'.repeat(gap)))
69  }
70
71  return lines
72}
73
74/** 3x5 arcade digits; `#` is lit. A sixth row is the drop shadow. */
75const FONT: Record<string, readonly string[]> = {
76  '0': ['###', '#.#', '#.#', '#.#', '###'],
77  '1': ['.#.', '##.', '.#.', '.#.', '###'],
78  '2': ['###', '..#', '###', '#..', '###'],
79  '3': ['###', '..#', '.##', '..#', '###'],
80  '4': ['#.#', '#.#', '###', '..#', '..#'],
81  '5': ['###', '#..', '###', '..#', '###'],
82  '6': ['###', '#..', '###', '#.#', '###'],
83  '7': ['###', '..#', '..#', '.#.', '.#.'],
84  '8': ['###', '#.#', '###', '#.#', '###'],
85  '9': ['###', '#.#', '###', '..#', '###'],
86}
87
88/** One digit: yellow top row, orange body, brown shadow under the base. */
89const digitGrid = (digit: string, lit: string, shadow: string): string[] => {
90  const glyph = FONT[digit] ?? FONT['0'] ?? []
91  const rows = glyph.map((line, y) => line.replace(/#/g, y === 0 ? 'y' : lit))
92  const base = glyph[glyph.length - 1] ?? '...'
93
94  return [...rows, base.replace(/#/g, shadow)]
95}
96
97/** A number in big pixel digits, 6 pixels (3 text rows) tall. */
98export const digitsGrid = (value: number, lit = 'o', shadow = 'b'): string[] =>
99  besides(String(Math.max(0, Math.floor(value))).split('').map(digit => digitGrid(digit, lit, shadow)), 1)
100
101/** The 1UP heart, 7x6. */
102export const HEART: readonly string[] = [
103  '.rr.rr.',
104  'riirrrr',
105  'rirrrrr',
106  '.rrrrr.',
107  '..rrr..',
108  '...r...',
109]
110
111/** The heart once the lives ran out: grey and cracked. */
112export const BROKEN_HEART: readonly string[] = [
113  '.dd.dd.',
114  'dddkddd',
115  'ddkdddd',
116  '.ddkdd.',
117  '..dkd..',
118  '...d...',
119]
120
types/index.d.ts 25 lines
1export type RespawnPhase = 'idle' | 'countdown' | 'gameover'
2
3export type RespawnRun = {
4  /** idle: nothing to say; countdown: CONTINUE? is ticking; gameover: out of lives. */
5  phase: RespawnPhase
6  /** Continues spent since the last success or cancel: the backoff step. */
7  attempt: number
8  /** When the countdown ends, in ms since the epoch. */
9  dueAt: number
10  /** Whole seconds left on the countdown, as drawn. */
11  secondsLeft: number
12  /** When each life was spent, in ms since the epoch (last hour kept). */
13  spentAt: number[]
14  /** GAME OVER: whole minutes until the oldest spent life comes back (0 = none spent). */
15  minutesToLife: number
16  /** `/respawn off` switched it off for this session. */
17  isOff: boolean
18}
19
20declare module 'claude-code' {
21  interface PluginState {
22    respawn: { run: RespawnRun }
23  }
24}
25