SLOPSHOPPER

mod-surfer

Subway Surfers-style runner pane that reads Claude's replies aloud with OpenAI text-to-speech while a turn runs

newpanetoastpromptprocesstimer
★ 8v0.1.0MITupdated 2026-10-06kyh/kyh.io/apps/mod-surfer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-surfer
│ ┃ Subway Clauders ✕ › fix the failing auth test and add an audit log call │ ┃ SCORE 000000 │ ┃ ● 0 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ waiting ⎿ 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 │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subway Clauders
SCORE 000000 ● 0 waiting ○ idle · system voice Narration plays while Claude works.
README

mod-surfer

A Claude Code mod. While Claude works, a pane plays a self-running Subway Surfers-style runner, and an OpenAI voice reads Claude's replies aloud with captions underneath.

Requirements

  • Claude Code with mod (function hooks plugin) support, at or past the version packages/claude-code-types pins.
  • macOS to hear the OpenAI voice. Clips play through afplay. On Linux or Windows you get the system voice where one exists, otherwise captions only.
  • An OpenAI API key for the OpenAI voice. Without one, the system voice (say on macOS) reads instead.
  • curl on your PATH. It ships with macOS.
  • A terminal at least 144 columns wide. Narrower, the pane waits and appears once you widen it.

Set up

The mod has no npm dependencies at runtime: loading it is pointing Claude Code at this folder.

One session

From this repo:

export OPENAI_API_KEY=sk-...
pnpm dev:mod-surfer

That runs claude --plugin-dir apps/mod-surfer. From anywhere else:

claude --plugin-dir /path/to/kyh.io/apps/mod-surfer

Every session

Add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (an absolute path; ~ works). This is also how the desktop app loads it, since it takes no flags.

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "~/code/kyh.io/apps/mod-surfer",
    "OPENAI_API_KEY": "sk-..."
  }
}

Separate several folders with : (; on Windows). Claude Code reads this from your user settings only, never a project's.

Remove it

Drop the --plugin-dir flag, or the path from CLAUDE_CODE_PLUGIN_DIRS.

Set your API key

The mod looks for a key in this order:

  1. The plugin's openaiApiKey option. It's marked sensitive, so Claude Code keeps it in secure storage and doesn't list it in /config.
  2. The OPENAI_API_KEY environment variable, from your shell or the env block under Set up.

With neither, it uses the system voice; the status line under the game reads "system voice" instead of "openai ash". With a key on a machine that can't play clips, it does the same and also shows a toast saying why.

Use it

Start Claude Code with the mod loaded and send a prompt. While Claude works:

  • the runner moves, dodging trains, jumping barriers and collecting coins;
  • each sentence of the reply is read aloud and shown as a caption under the game;
  • the status line under the game shows ● LIVE or ○ idle, and which voice is reading.

When the turn ends, the game freezes on "waiting for Claude…" once the narration finishes.

Settings

Open /config and find mod-surfer:

SettingDefaultOptions
Narrator voiceashalloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse
Speech modelgpt-4o-mini-ttsgpt-4o-mini-tts, tts-1, tts-1-hd
Mute narratoroffon: captions only, no voice and no OpenAI requests

Only gpt-4o-mini-tts follows the upbeat narrator style. The other two read in a flat voice.

You can also set them in ~/.claude/settings.json:

{
  "pluginConfigs": {
    "mod-surfer": { "options": { "voice": "nova" } }
  }
}

What gets read

  • Only Claude's own replies in the main conversation. Subagents and tool output aren't read.
  • Markdown is stripped. Code blocks become "(code omitted.)", links read as their text, and bare URLs read as "a link".
  • Each sentence is one OpenAI request. The next two are fetched while the current one plays, to keep gaps short.
  • A long reply is read from its start, up to 30 sentences. The rest is dropped.
  • Sending a new prompt drops anything not yet read. A sentence that has already started finishes.

Cost

Each sentence is a separate call to OpenAI's speech endpoint, billed by OpenAI per input character at the model's rate. Long replies cost more. Turn on Mute narrator to stop paying while keeping captions.

Troubleshooting

SymptomCause and fix
No paneThe terminal is too narrow (see Requirements). Widen it.
Toast: "OpenAI voice plays on macOS only"You're not on macOS. The system voice or captions take over.
Toast: "OpenAI speech failed"The request failed. Check the key, your OpenAI quota, and network access to api.openai.com.
Toast: "no speech synthesizer here"No system voice either. You get captions only.
Nothing happens at allStart with claude --debug and look for lines starting with mod-surfer:.

Develop

Claude Code watches a --plugin-dir folder in an interactive session: saving a file in hooks/ reloads the mod without a restart.

pnpm -F @repo/mod-surfer test       # node: the runner and the narration lines
pnpm -F @repo/mod-surfer test:mod   # claude plugin test: the hooks: the pane, the voice fallbacks, mute
pnpm -F @repo/mod-surfer typecheck  # pure modules + node tests, then the hooks against the pinned API types
pnpm -F @repo/mod-surfer validate   # what Claude Code will load

Tests come in two kinds, split by suffix. test/*.spec.ts cover the pure modules under node and run in pnpm test and CI. test/*.test.ts run the hooks inside Claude Code's own test kit, with the engine beneath mocked (claude-code/testing); they need the claude CLI, so they're local only. claude plugin test loads every *.test.ts in the folder, which is why the node tests can't share the suffix.

The hooks type-check against packages/claude-code-types, the plugin API's declarations pinned from the Claude Code version on that file's first line. After a Claude Code update, refresh it from the copy the engine writes beside a mod each time it loads one:

cp apps/mod-surfer/.claude-plugin/types/claude-code/index.d.ts packages/claude-code-types/claude-code.d.ts
FileRole
hooks/register.tsxThe hooks: pane, narration queue, speech, frame loop
hooks/game.tsRunner simulation, autopilot and drawing
hooks/narrate.tsMarkdown to sentences, and the OpenAI request
types/index.d.tsThe $.state contract: the caption, whether a turn runs
.claude-plugin/plugin.jsonManifest and the /config options
Source 4 files
hooks/register.tsx 286 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Caption } from "../types";
5
6import { draw, encode, newGame, step } from "./game";
7import { SPEECH_SCRIPT, readMs, speechRequest, toSentences } from "./narrate";
8
9const PANE = "subway";
10const TITLE = "Subway Clauders";
11const FRAME_MS = 66;
12const MAX_QUEUE = 30;
13
14const caption = atom({ key: "caption", plugin: "mod-surfer" } as const, "");
15const isRunning = atom({ key: "isRunning", plugin: "mod-surfer" } as const, false);
16
17// One line of narration and, once asked for, its mp3 as base64 (empty when
18// synthesis failed).
19interface Line {
20  text: Caption;
21  audio?: Promise<string>;
22}
23
24// Frame-rate values live in the module: a reload starts a fresh run.
25const game = newGame();
26const queue: Line[] = [];
27const openai = { key: "", model: "gpt-4o-mini-tts", voice: "ash" };
28let size: { cols: number; rows: number } | null = null;
29let running = false;
30let muted = false;
31let speaking = false;
32let canSpeak = true;
33let pausedFrameSent = false;
34
35const runSpeech = async ($: EngineInterface, text: Caption) => {
36  try {
37    const { exitCode, stdout } = await $.process.run(["sh", "-c", SPEECH_SCRIPT], {
38      env: { OPENAI_API_KEY: openai.key },
39      stdin: speechRequest(text, openai.voice, openai.model),
40      timeoutMs: 40_000,
41    });
42    return exitCode === 0 ? stdout.trim() : "";
43  } catch {
44    return "";
45  }
46};
47
48const synthesize = ($: EngineInterface, line: Line) => {
49  line.audio ??= runSpeech($, line.text);
50  return line.audio;
51};
52
53// Synthesizes the next lines while the current one plays, so lines run on
54// without a request's wait between them.
55const prefetch = ($: EngineInterface) => {
56  if (!openai.key || muted) {
57    return;
58  }
59  for (const line of queue.slice(0, 2)) {
60    void synthesize($, line);
61  }
62};
63
64// Plays the line with OpenAI's voice; false when nothing could be played.
65const playOpenai = async ($: EngineInterface, line: Line) => {
66  const audio = await synthesize($, line);
67  if (!audio) {
68    openai.key = "";
69    $.ui.toast("mod-surfer: OpenAI speech failed, using the system voice");
70    return false;
71  }
72  await update($, caption, () => line.text);
73  try {
74    await $.audio.play({ base64: audio, mime: "audio/mpeg" });
75    return true;
76  } catch {
77    // Stop paying for speech nobody hears; the system voice takes over.
78    openai.key = "";
79    $.ui.toast("mod-surfer: OpenAI audio would not play, using the system voice");
80    return false;
81  }
82};
83
84// `$.audio.play` plays clips through afplay, so only macOS hears them.
85const canPlayClips = async ($: EngineInterface) => {
86  try {
87    const { stdout } = await $.process.run(["uname", "-s"]);
88    return stdout.trim() === "Darwin";
89  } catch {
90    return false;
91  }
92};
93
94const playSystem = async ($: EngineInterface, line: Line) => {
95  await update($, caption, () => line.text);
96  if (!canSpeak) {
97    return false;
98  }
99  try {
100    await $.audio.speak(line.text);
101    return true;
102  } catch {
103    canSpeak = false;
104    $.ui.toast("mod-surfer: no speech synthesizer here, captions only");
105    return false;
106  }
107};
108
109// Whether the line was heard; when not, its caption still shows.
110const voice = async ($: EngineInterface, line: Line) => {
111  if (muted) {
112    await update($, caption, () => line.text);
113    return false;
114  }
115  if (openai.key) {
116    const isPlayed = await playOpenai($, line);
117    if (isPlayed || openai.key) {
118      return isPlayed;
119    }
120  }
121  return playSystem($, line);
122};
123
124// Says the next queued line, then schedules the one after it; a line nobody
125// heard stays up for as long as it takes to read.
126const sayNext = async ($: EngineInterface) => {
127  const line = queue.shift();
128  if (!line) {
129    speaking = false;
130    $.clock.after(1500, () => {
131      if (!speaking) {
132        void update($, caption, () => "");
133      }
134    });
135    return;
136  }
137  prefetch($);
138  let isHeard = false;
139  try {
140    isHeard = await voice($, line);
141  } catch {
142    // A failed line still keeps the queue moving.
143    isHeard = false;
144  }
145  $.clock.after(isHeard ? 1 : readMs(line.text), () => {
146    void sayNext($);
147  });
148};
149
150const narrate = ($: EngineInterface) => {
151  if (speaking) {
152    return;
153  }
154  speaking = true;
155  $.clock.after(1, () => {
156    void sayNext($);
157  });
158};
159
160const frame = async ($: EngineInterface) => {
161  if (!size) {
162    return;
163  }
164  const isActive = running || speaking;
165  if (isActive) {
166    step(game, FRAME_MS / 1000);
167    pausedFrameSent = false;
168  } else if (pausedFrameSent) {
169    return;
170  } else {
171    pausedFrameSent = true;
172  }
173  const cells = encode(draw(game, size.cols, size.rows, !isActive));
174  try {
175    const { deny } = await $.ui.blit({ cells, key: "game", requestId: PANE });
176    if (deny) {
177      size = null;
178    }
179  } catch {
180    // The pane is closed: stop drawing until it renders again.
181    size = null;
182  }
183};
184
185const statusOf = (isActive: boolean, isQuiet: boolean) => {
186  const parts = [isActive ? "● LIVE" : "○ idle"];
187  if (isQuiet) {
188    parts.push("muted");
189  } else {
190    parts.push(openai.key ? `openai ${openai.voice}` : "system voice");
191  }
192  return `${parts.join(" · ")}${speaking && !isQuiet ? " 🔊" : ""}`;
193};
194
195export const register: Register = (on, options) => {
196  openai.voice = String(options.voice ?? openai.voice);
197  openai.model = String(options.model ?? openai.model);
198  muted = options.mute === true;
199
200  on("session.start", async ($, e, next) => {
201    const key = String(options.openaiApiKey ?? "") || ((await $.env.get("OPENAI_API_KEY")) ?? "");
202    openai.key = key && (await canPlayClips($)) ? key : "";
203    if (key && !openai.key) {
204      $.ui.toast("mod-surfer: OpenAI voice plays on macOS only, using the system voice");
205    }
206    void $.ui.open({ id: PANE, title: TITLE });
207    // oxlint-disable-next-line unicorn/no-array-method-this-argument -- $.clock.every is a timer, not Array#every
208    $.clock.every(FRAME_MS, () => {
209      void frame($);
210    });
211
212    return next(e);
213  });
214
215  // A new prompt makes the rest of the last reply stale.
216  on("prompt.submit", ($, e, next) => {
217    queue.length = 0;
218    return next(e);
219  });
220
221  on("turn.start", async ($, e, next) => {
222    running = true;
223    await update($, isRunning, () => true);
224    return next(e);
225  });
226
227  on("turn.complete", async ($, e, next) => {
228    running = false;
229    await update($, isRunning, () => false);
230    return next(e);
231  });
232
233  on("session.append", ($, e, next) => {
234    if (e.agentId || e.door !== "response" || e.message.role !== "assistant") {
235      return next(e);
236    }
237    const text = e.message.content
238      .flatMap((block) => (block.type === "text" ? [String(block.text)] : []))
239      .join("\n");
240    // A long reply is read from its start; lines past the cap are dropped.
241    const room = Math.max(0, MAX_QUEUE - queue.length);
242    queue.push(
243      ...toSentences(text)
244        .slice(0, room)
245        .map((sentence) => ({ text: sentence })),
246    );
247    prefetch($);
248    narrate($);
249
250    return next(e);
251  });
252
253  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
254    const line = await read($, caption);
255    const isActive = await read($, isRunning);
256    const status = statusOf(isActive, muted);
257
258    if (e.surface !== "terminal") {
259      const { Box, Text } = $.ui.resolve(e);
260      return (
261        <Box flexDirection="column">
262          <Text dimColor>{status}</Text>
263          <Text bold>{line || "…"}</Text>
264        </Box>
265      );
266    }
267
268    const { Box, Raster, Text } = $.ui.resolve(e);
269    const cols = Math.max(20, Math.min(512, e.props.bodyColumns));
270    const rows = Math.max(8, Math.min(256, (e.viewport?.rows ?? 30) - 10));
271    size = { cols, rows };
272    pausedFrameSent = false;
273    const cells = encode(draw(game, cols, rows, !(running || speaking)));
274
275    return (
276      <Box flexDirection="column">
277        <Raster key="game" columns={cols} rows={rows} cells={cells} />
278        <Text dimColor>{status}</Text>
279        <Text bold color="yellow" wrap="wrap">
280          {line || (isActive ? "…" : "Narration plays while Claude works.")}
281        </Text>
282      </Box>
283    );
284  });
285};
286
hooks/game.ts 364 lines
1// A self-playing three-lane endless runner drawn into a Raster's cells.
2
3export type Kind = "train" | "barrier" | "coin";
4export interface Thing {
5  lane: number;
6  d: number;
7  kind: Kind;
8  color: number;
9}
10export interface Game {
11  time: number;
12  scroll: number;
13  lane: number;
14  x: number;
15  jump: number;
16  things: Thing[];
17  score: number;
18  coins: number;
19  crash: number;
20  seed: number;
21  nextSpawn: number;
22}
23
24const FAR = 16;
25const RUNNER_D = 0.4;
26const JUMP_SECONDS = 0.7;
27const U32 = 2 ** 32;
28// The terminal's default color, as a Raster cell spells it.
29const DEFAULT = 0x01_00_00_00;
30const TRAIN_COLORS = [0xc0_39_2b, 0x2e_6f_d8, 0xe6_7e_22, 0x8e_44_ad];
31const COIN = 0xf1_c4_0f;
32const WHITE = 0xff_ff_ff;
33const BLACK = 0x00_00_00;
34
35export const newGame = (seed = 7): Game => ({
36  coins: 0,
37  crash: 0,
38  jump: -1,
39  lane: 1,
40  nextSpawn: 2,
41  score: 0,
42  scroll: 0,
43  seed,
44  things: [],
45  time: 0,
46  x: 1,
47});
48
49// A linear congruential generator, exact in doubles: the product stays under 2^53.
50const rand = (g: Game) => {
51  g.seed = (g.seed * 1_664_525 + 1_013_904_223) % U32;
52  return g.seed / U32;
53};
54
55const hash = (n: number) => {
56  const s = Math.sin(n * 12.9898) * 43_758.5453;
57  return s - Math.floor(s);
58};
59
60const channel = (color: number, shift: number) => Math.floor(color / shift) % 256;
61
62const mix = (a: number, b: number, t: number) => {
63  const c = (shift: number) =>
64    Math.round(channel(a, shift) * (1 - t) + channel(b, shift) * t) * shift;
65  return c(65_536) + c(256) + c(1);
66};
67
68const speedOf = (g: Game) => 6 + Math.min(g.score / 3000, 5);
69
70const spawn = (g: Game) => {
71  const blocked = rand(g) < 0.35 ? 2 : 1;
72  const lanes = [0, 1, 2]
73    .map((lane) => ({ lane, order: rand(g) }))
74    .toSorted((a, b) => a.order - b.order)
75    .map(({ lane }) => lane);
76  for (const lane of lanes.slice(0, blocked)) {
77    const kind: Kind = rand(g) < 0.6 ? "train" : "barrier";
78    const color = TRAIN_COLORS[Math.floor(rand(g) * TRAIN_COLORS.length)] ?? COIN;
79    g.things.push({ color, d: FAR, kind, lane });
80  }
81  const free = lanes[blocked];
82  if (free !== undefined && rand(g) < 0.7) {
83    for (let i = 0; i < 4; i += 1) {
84      g.things.push({ color: COIN, d: FAR + i * 0.9, kind: "coin", lane: free });
85    }
86  }
87  g.nextSpawn = 4.5 + rand(g) * 3;
88};
89
90// Nearest train ahead in a lane (barriers are jumped, so they do not block).
91const danger = (g: Game, lane: number) =>
92  Math.min(
93    ...g.things
94      .filter((t) => t.lane === lane && t.kind === "train" && t.d > RUNNER_D - 0.2)
95      .map((t) => t.d),
96    Number.POSITIVE_INFINITY,
97  );
98
99const coinsAhead = (g: Game, lane: number) =>
100  g.things.filter((t) => t.lane === lane && t.kind === "coin" && t.d > RUNNER_D && t.d < 8).length;
101
102const barrierAhead = (g: Game, lane: number, reach: number) =>
103  g.things.some(
104    (t) => t.kind === "barrier" && t.lane === lane && t.d > RUNNER_D && t.d < RUNNER_D + reach,
105  );
106
107const think = (g: Game) => {
108  const value = (lane: number) =>
109    Math.min(danger(g, lane), 12) * 2 +
110    coinsAhead(g, lane) -
111    Math.abs(lane - g.lane) * 1.5 -
112    (barrierAhead(g, lane, 3) ? 3 : 0);
113  let target = g.lane;
114  for (const lane of [0, 1, 2]) {
115    if (value(lane) > value(target)) {
116      target = lane;
117    }
118  }
119  const next = g.lane + Math.sign(target - g.lane);
120  if (next !== g.lane && danger(g, next) > 1.6) {
121    g.lane = next;
122  }
123  const reach = 0.4 + speedOf(g) * 0.2;
124  const mustJump = barrierAhead(g, Math.round(g.x), reach) || barrierAhead(g, g.lane, reach);
125  if (mustJump && (g.jump < 0 || g.jump > 0.6)) {
126    g.jump = 0;
127  }
128};
129
130export const step = (g: Game, dt: number) => {
131  const moved = speedOf(g) * dt;
132  g.time += dt;
133  g.scroll += moved;
134  g.score += moved * 10;
135  g.crash = Math.max(0, g.crash - dt);
136  if (g.jump >= 0) {
137    g.jump += dt / JUMP_SECONDS;
138    if (g.jump >= 1) {
139      g.jump = -1;
140    }
141  }
142  g.nextSpawn -= moved;
143  if (g.nextSpawn <= 0) {
144    spawn(g);
145  }
146
147  think(g);
148  const diff = g.lane - g.x;
149  g.x += Math.sign(diff) * Math.min(Math.abs(diff), dt * 9);
150
151  const runnerLane = Math.round(g.x);
152  const isAirborne = g.jump >= 0;
153  g.things = g.things.filter((t) => {
154    t.d -= moved;
155    if (t.d < -1) {
156      return false;
157    }
158    if (t.lane !== runnerLane || Math.abs(t.d - RUNNER_D) >= 0.35) {
159      return true;
160    }
161    if (t.kind === "coin") {
162      g.coins += 1;
163      return false;
164    }
165    if (t.kind === "barrier" && isAirborne) {
166      return true;
167    }
168    g.crash = 0.6;
169    return false;
170  });
171};
172
173// A grid of cells and the pen that writes them.
174const canvas = (cols: number, rows: number) => {
175  const cells = new Uint32Array(cols * rows * 3);
176  const at = (x: number, y: number) => {
177    const cx = Math.round(x);
178    const cy = Math.round(y);
179    return cx >= 0 && cx < cols && cy >= 0 && cy < rows ? (cy * cols + cx) * 3 : -1;
180  };
181  // Writes a glyph; with no background the cell keeps the one it has.
182  const put = (x: number, y: number, ch: string, fg: number, bg?: number) => {
183    const i = at(x, y);
184    if (i < 0) {
185      return;
186    }
187    cells[i] = ch.codePointAt(0) ?? 32;
188    cells[i + 1] = fg;
189    if (bg !== undefined) {
190      cells[i + 2] = bg;
191    }
192  };
193  const text = (x: number, y: number, s: string, fg: number, bg?: number) => {
194    for (const [k, ch] of [...s].entries()) {
195      put(x + k, y, ch, fg, bg);
196    }
197  };
198  return { cells, cols, put, rows, text };
199};
200type Canvas = ReturnType<typeof canvas>;
201
202// The perspective: depth `d` maps to a row, a road width and lane centers.
203const view = (c: Canvas) => {
204  const hy = Math.max(2, Math.floor(c.rows * 0.3));
205  const ground = c.rows - hy;
206  const k = (ground - 1) / (FAR + 1);
207  const cx = c.cols / 2;
208  const roadWidth = (t: number) => Math.min(c.cols * 0.98, c.cols * (0.1 + 0.9 * t));
209  const laneX = (t: number, lane: number) => cx + (lane - 1) * (roadWidth(t) / 3);
210  const tOf = (d: number) => 1 / (1 + Math.max(0, d) * k);
211  const rowOf = (d: number) => hy - 1 + tOf(d) * ground;
212  return { cx, ground, hy, k, laneX, roadWidth, rowOf, tOf };
213};
214type View = ReturnType<typeof view>;
215
216const drawSky = (c: Canvas, v: View, g: Game) => {
217  for (let y = 0; y < v.hy; y += 1) {
218    const bg = mix(0x1b_14_46, 0xff_8a_4c, y / v.hy);
219    for (let x = 0; x < c.cols; x += 1) {
220      c.put(x, y, " ", DEFAULT, bg);
221    }
222  }
223  const drift = g.scroll * 0.4;
224  for (let x = 0; x < c.cols; x += 1) {
225    const block = Math.floor((x + drift) / 5);
226    const height = Math.floor(hash(block) * v.hy * 0.7);
227    for (let y = v.hy - height; y < v.hy; y += 1) {
228      const isLit = hash(block * 31 + y) < 0.15 && (x + Math.floor(drift)) % 2 === 0;
229      c.put(x, y, isLit ? "▪" : " ", 0xff_d3_6b, 0x2a_21_40);
230    }
231  }
232};
233
234const groundColor = (isRoad: boolean, isStripe: boolean) => {
235  if (isRoad) {
236    return isStripe ? 0x7a_4a_26 : 0x5b_50_48;
237  }
238  return isStripe ? 0x2f_5a_2a : 0x3c_6e_33;
239};
240
241// Ties scroll toward the viewer at the speed things approach.
242const drawTrack = (c: Canvas, v: View, g: Game) => {
243  for (let y = v.hy; y < c.rows; y += 1) {
244    const t = (y - v.hy + 1) / v.ground;
245    const w = v.roadWidth(t);
246    const left = v.cx - w / 2;
247    const phase = (((1 / t + g.scroll * v.k) * 0.9) % 1) + 1;
248    const isStripe = phase % 1 < 0.28;
249    for (let x = 0; x < c.cols; x += 1) {
250      c.put(x, y, " ", DEFAULT, groundColor(x >= left && x < left + w, isStripe));
251    }
252    for (const lane of [0, 1, 2]) {
253      const lx = v.laneX(t, lane);
254      c.put(lx - w / 12, y, " ", DEFAULT, 0xc9_cc_d6);
255      c.put(lx + w / 12, y, " ", DEFAULT, 0xc9_cc_d6);
256    }
257  }
258};
259
260const trainCell = (thing: Thing, row: number, height: number, isInner: boolean) => {
261  if (row === 0) {
262    return mix(thing.color, WHITE, 0.3);
263  }
264  if (height > 3 && row === Math.floor(height / 3) && isInner) {
265    return 0x9f_d3_ff;
266  }
267  return thing.color;
268};
269
270// Black and yellow checks.
271const barrierCell = (x: number, y: number) => ((x + y) % 2 === 0 ? COIN : 0x22_22_22);
272
273const drawThing = (c: Canvas, v: View, thing: Thing) => {
274  const t = v.tOf(thing.d);
275  const base = Math.round(v.rowOf(thing.d));
276  const lx = v.laneX(t, thing.lane);
277  const half = Math.max(0.5, (v.roadWidth(t) / 3) * 0.4);
278  if (thing.kind === "coin") {
279    if (half < 1.5) {
280      c.put(lx, base - 1, "●", COIN);
281      return;
282    }
283    for (let x = lx - half / 2; x <= lx + half / 2; x += 1) {
284      c.put(x, base - 1, "●", 0xff_f3_b0, COIN);
285    }
286    return;
287  }
288  const scale = thing.kind === "train" ? 0.9 : 0.25;
289  const height = Math.max(1, Math.round(scale * v.ground * t * 1.4));
290  const top = base - height + 1;
291  for (let y = top; y <= base; y += 1) {
292    for (let x = Math.round(lx - half); x <= Math.round(lx + half); x += 1) {
293      const bg =
294        thing.kind === "barrier"
295          ? barrierCell(x, y)
296          : trainCell(thing, y - top, height, Math.abs(x - lx) < half - 0.5);
297      c.put(x, y, " ", DEFAULT, bg);
298    }
299  }
300};
301
302const legsOf = (g: Game, isPaused: boolean) => {
303  if (isPaused) {
304    return [" ", "║", " "];
305  }
306  return Math.floor(g.time * 10) % 2 === 0 ? ["/", " ", "\\"] : [" ", "│", " "];
307};
308
309const drawRunner = (c: Canvas, v: View, g: Game, isPaused: boolean) => {
310  const rx = v.laneX(1, g.x);
311  const lift = g.jump < 0 ? 0 : Math.round(Math.sin(Math.PI * g.jump) * 3);
312  const feet = c.rows - 1 - lift;
313  if (lift > 0) {
314    c.put(rx, c.rows - 1, "▁", 0x22_22_22);
315  }
316  c.text(rx - 1, feet, legsOf(g, isPaused).join(""), 0x1d_2b_53);
317  c.text(rx - 1, feet - 1, "▐█▌", 0x2e_cc_71);
318  c.put(rx, feet - 2, "●", 0xf5_c6_a5);
319  c.put(rx + 1, feet - 2, "▀", 0xe7_4c_3c);
320};
321
322const banner = (c: Canvas, v: View, label: string, bg: number) =>
323  c.text(Math.max(0, Math.floor(v.cx - label.length / 2)), Math.floor(v.hy / 2), label, WHITE, bg);
324
325export const draw = (g: Game, cols: number, rows: number, isPaused = false): Uint32Array => {
326  const c = canvas(cols, rows);
327  const v = view(c);
328  drawSky(c, v, g);
329  drawTrack(c, v, g);
330  for (const thing of g.things.toSorted((a, b) => b.d - a.d)) {
331    if (thing.d <= FAR && thing.d >= -0.5) {
332      drawThing(c, v, thing);
333    }
334  }
335  drawRunner(c, v, g, isPaused);
336  c.text(1, 0, `SCORE ${String(Math.floor(g.score)).padStart(6, "0")}`, WHITE);
337  c.text(1, 1, `● ${g.coins}`, COIN);
338  if (g.crash > 0) {
339    banner(c, v, " OUCH! ", 0xc0_39_2b);
340  }
341  if (isPaused) {
342    banner(c, v, " waiting for Claude… ", BLACK);
343  }
344  return c.cells;
345};
346
347const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
348
349// Standard padded base64 of the cells' little-endian bytes, which is how a
350// Raster takes them; the hooks sandbox has no Buffer.
351export const encode = (words: Uint32Array): string => {
352  const bytes = new Uint8Array(words.buffer, words.byteOffset, words.byteLength);
353  const out: string[] = [];
354  for (let i = 0; i < bytes.length; i += 3) {
355    const count = Math.min(3, bytes.length - i);
356    const n = (bytes[i] ?? 0) * 65_536 + (bytes[i + 1] ?? 0) * 256 + (bytes[i + 2] ?? 0);
357    for (let k = 0; k < 4; k += 1) {
358      const digit = Math.floor(n / 64 ** (3 - k)) % 64;
359      out.push(k <= count ? (B64[digit] ?? "=") : "=");
360    }
361  }
362  return out.join("");
363};
364
hooks/narrate.ts 64 lines
1// Turns a markdown reply into short plain-text lines worth reading aloud.
2
3const MAX_LINE = 400;
4
5// A sentence ends at .!? and whitespace, unless the period closes a common
6// abbreviation, or right after a CJK full stop.
7const SENTENCE_END =
8  /(?<!\b(?:e\.g|i\.e|vs|etc|cf|approx|mr|mrs|ms|dr|st|no)\.)(?<=[.!?])\s+|(?<=[。!?])/iu;
9
10// Scripts read a character, not a word, at a time.
11const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu;
12
13const NARRATOR_STYLE =
14  "Fast, upbeat, slightly breathless social-video narrator reading over mobile gameplay footage. Keep it punchy and clear.";
15
16export const toSentences = (markdown: string): string[] => {
17  const plain = markdown
18    .replaceAll(/```[\s\S]*?(?:```|$)/gu, "\n(code omitted.)\n")
19    .replaceAll(/`(?<code>[^`]*)`/gu, "$<code>")
20    .replaceAll(/!\[[^\]]*\]\([^)]*\)/gu, "")
21    .replaceAll(/\[(?<label>[^\]]+)\]\([^)]*\)/gu, "$<label>")
22    .replaceAll(/https?:\/\/\S+?(?<end>[.,!?;:)]*)(?=\s|$)/gmu, "a link$<end>")
23    .replaceAll(/^[ \t]*(?:\|?[ \t:|-]+\|[ \t:|-]*|[-*_]{3,}[ \t]*)$/gmu, "")
24    .replaceAll(/[ \t]*\|[ \t]*/gu, ", ")
25    .replaceAll(/^\s{0,3}(?:#{1,6}|>|[-*+]|\d+[.)])\s+/gmu, "")
26    .replaceAll(/[*_~]+/gu, "");
27
28  return plain
29    .split("\n")
30    .flatMap((line) => line.split(SENTENCE_END))
31    .map((s) => s.replaceAll(/^[\s,]+|[\s,]+$/gu, "").replaceAll(/\s+/gu, " "))
32    .filter((s) => /[\p{L}\p{N}]/u.test(s))
33    .map((s) => (s.length > MAX_LINE ? `${s.slice(0, MAX_LINE)}…` : s));
34};
35
36// How long a caption stays up when nothing speaks it.
37export const readMs = (sentence: string) => {
38  const words = sentence.split(/\s+/u).length * 330;
39  const characters = (sentence.match(CJK) ?? []).length * 160;
40  return Math.min(8000, Math.max(1200, words, characters));
41};
42
43// The JSON body of one OpenAI /v1/audio/speech request; only the gpt speech
44// models take `instructions`.
45export const speechRequest = (input: string, voice: string, model: string) => {
46  const body = { input, model, response_format: "mp3", voice };
47  return JSON.stringify(
48    model.startsWith("gpt-") ? { ...body, instructions: NARRATOR_STYLE } : body,
49  );
50};
51
52// Posts the body from stdin and prints the mp3 as one line of base64: the
53// hooks sandbox reads process output as text, so raw audio would not survive.
54// The mp3 lands in a temp file first so a failed or cut-off download exits
55// non-zero instead of printing partial audio.
56export const SPEECH_SCRIPT = [
57  'f="$(mktemp)" || exit 1',
58  "trap 'rm -f \"$f\"' EXIT",
59  'curl -sS --fail --connect-timeout 10 -m 30 -o "$f" https://api.openai.com/v1/audio/speech ' +
60    '-H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" ' +
61    "--data-binary @- || exit 1",
62  "base64 < \"$f\" | tr -d '\\n'",
63].join("\n");
64
types/index.d.ts 8 lines
1export type Caption = string;
2
3declare module "claude-code" {
4  interface PluginState {
5    "mod-surfer": { caption: Caption; isRunning: boolean };
6  }
7}
8