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

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.
packages/claude-code-types pins.afplay. On Linux or Windows you get the system voice where one exists, otherwise captions only.say on macOS) reads instead.curl on your PATH. It ships with macOS.The mod has no npm dependencies at runtime: loading it is pointing Claude Code at this folder.
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
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.
Drop the --plugin-dir flag, or the path from CLAUDE_CODE_PLUGIN_DIRS.
The mod looks for a key in this order:
openaiApiKey option. It's marked sensitive, so Claude Code keeps it in secure storage and doesn't list it in /config.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.
Start Claude Code with the mod loaded and send a prompt. While Claude works:
● LIVE or ○ idle, and which voice is reading.When the turn ends, the game freezes on "waiting for Claude…" once the narration finishes.
Open /config and find mod-surfer:
| Setting | Default | Options |
|---|---|---|
| Narrator voice | ash | alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse |
| Speech model | gpt-4o-mini-tts | gpt-4o-mini-tts, tts-1, tts-1-hd |
| Mute narrator | off | on: 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" } }
}
}
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.
| Symptom | Cause and fix |
|---|---|
| No pane | The 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 all | Start with claude --debug and look for lines starting with mod-surfer:. |
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
| File | Role |
|---|---|
hooks/register.tsx | The hooks: pane, narration queue, speech, frame loop |
hooks/game.ts | Runner simulation, autopilot and drawing |
hooks/narrate.ts | Markdown to sentences, and the OpenAI request |
types/index.d.ts | The $.state contract: the caption, whether a turn runs |
.claude-plugin/plugin.json | Manifest and the /config options |
hooks/register.tsx 286 lines1import { 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};
286hooks/game.ts 364 lines1// 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};
364hooks/narrate.ts 64 lines1// 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");
64types/index.d.ts 8 lines1export type Caption = string;
2
3declare module "claude-code" {
4 interface PluginState {
5 "mod-surfer": { caption: Caption; isRunning: boolean };
6 }
7}
8