Play Sudoku or TTS (teka-teki silang) in a pane while Claude is cooking

A Claude Code mod: play Sudoku, TTS (teka-teki silang) or Nokia-style Snake in a pane while Claude is cooking.
When you send a prompt, a puzzle pane opens. When Claude finishes, you get a toast — press Esc to go back to the prompt. Your game is saved between turns.
Don't want it popping up? /puzzle off — the games then only open when you ask (/sudoku, /tts, /snake).
/plugin install cooking-puzzles --marketplace Tamlica/cooking-puzzles-mod
Answer y to add the marketplace, then pick a scope.
Sudoku — every puzzle has exactly one solution.
| Key | Action |
|---|---|
w a s d | move |
1–9 | fill |
0 | erase |
n | new puzzle |
g | switch to TTS |
k | switch to Snake |
TTS — every new crossword is written by Claude (Haiku) on a random theme (makanan, hewan, teknologi, sejarah…), avoiding words you've seen recently. If that fails or you turn it off, it uses a built-in list of ~50 Indonesian words. Type the answer and press Enter (empty Enter = next clue). Tab to pick a clue, petunjuk reveals a letter.
Snake — like the old Nokia, on a 3310-green screen. Walls wrap around (Snake II style), and the snake speeds up as it grows. Click the board to give it the keyboard first.
| Key | Action |
|---|---|
arrows / w a s d / h j k l | steer (and start) |
space / p | pause |
r | restart |
Your best score is kept across sessions.
/sudoku, /tts, /snake — open a game any time/puzzle on|off — auto-open while Claude works (on by default). Off is fully quiet: no pane, no status line/puzzle sudoku|tts|snake — which game auto-opens/puzzle — show the current settings/puzzle ai on|off — Claude-written TTS words (on by default; each new TTS is one small Haiku call)Your games in progress, settings and Snake best score are saved, so they carry over to your next session.
To turn the whole mod off, disable or uninstall it from the /plugin menu.
claude --plugin-dir . # run it from this folder
claude plugin validate .
claude plugin test .hooks/register.tsx 444 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Game, Sudoku, Tts } from '../types'
5import {
6 answerTts,
7 hintTts,
8 isSudokuSolved,
9 moveCursor,
10 newSudoku,
11 newTts,
12 nextUnsolved,
13 parseEntries,
14 pickTheme,
15 ttsPrompt,
16 wordCells,
17} from './games'
18
19const PANE = 'puzzle'
20
21const game = atom({ plugin: 'cooking-puzzles', key: 'game' } as const, 'sudoku' as Game)
22const sudoku = atom({ plugin: 'cooking-puzzles', key: 'sudoku' } as const, null as Sudoku | null)
23const tts = atom({ plugin: 'cooking-puzzles', key: 'tts' } as const, null as Tts | null)
24const auto = atom({ plugin: 'cooking-puzzles', key: 'auto' } as const, true)
25const cooking = atom({ plugin: 'cooking-puzzles', key: 'cooking' } as const, false)
26const draft = atom({ plugin: 'cooking-puzzles', key: 'draft' } as const, '')
27const snakeBest = atom({ plugin: 'cooking-puzzles', key: 'snakeBest' } as const, 0)
28const ttsAi = atom({ plugin: 'cooking-puzzles', key: 'ttsAi' } as const, true)
29const ttsLoading = atom({ plugin: 'cooking-puzzles', key: 'ttsLoading' } as const, false)
30
31// Writes of the values saved to the plugin's store, so they outlive the session
32type $ = EngineInterface
33const setGame = async ($: $, fn: (v: Game) => Game) => $.store.set('game', await update($, game, fn))
34const setAuto = async ($: $, fn: (v: boolean) => boolean) => $.store.set('auto', await update($, auto, fn))
35const setAi = async ($: $, fn: (v: boolean) => boolean) => $.store.set('ttsAi', await update($, ttsAi, fn))
36const setBest = async ($: $, fn: (v: number) => number) => $.store.set('snakeBest', await update($, snakeBest, fn))
37const setSudoku = async ($: $, fn: (v: Sudoku | null) => Sudoku | null) =>
38 $.store.set('sudoku', await update($, sudoku, fn))
39const setTts = async ($: $, fn: (v: Tts | null) => Tts | null) => $.store.set('tts', await update($, tts, fn))
40
41/** A new TTS: words Claude writes on a random theme, else the built-in list. */
42const startTts = async ($: EngineInterface) => {
43 if (await read($, ttsLoading)) return
44 await update($, ttsLoading, () => true)
45 try {
46 const recent = ((await $.store.get('ttsRecent')) as string[] | undefined) ?? []
47 let puzzle: Tts | null = null
48 if (await read($, ttsAi)) {
49 const theme = pickTheme()
50 try {
51 const reply = await $.model.complete({
52 model: 'haiku',
53 prompt: ttsPrompt(theme, recent.slice(-40)),
54 maxTokens: 2000,
55 timeoutMs: 45000,
56 })
57 const entries = reply.isAnswered ? parseEntries(reply.text) : []
58 if (entries.length >= 8) {
59 const made = newTts(8, entries)
60 if (made.words.length >= 5) puzzle = { ...made, theme }
61 }
62 } catch {}
63 if (!puzzle) $.ui.toast('Claude gagal bikin soal baru, pakai soal bawaan')
64 }
65 const t = puzzle ?? newTts()
66 await setTts($, () => t)
67 await $.store.set('ttsRecent', [...recent, ...t.words.map(w => w.answer)].slice(-60))
68 } finally {
69 await update($, ttsLoading, () => false)
70 }
71}
72
73const TITLE: Record<Game, string> = { sudoku: 'Sudoku', tts: 'TTS', snake: 'Snake' }
74const ROWS: Record<Game, number> = { sudoku: 18, tts: 22, snake: 17 }
75const isGame = (x: string): x is Game => x in TITLE
76
77const openPane = async ($: EngineInterface, which?: Game) => {
78 if (which) await setGame($, () => which)
79 const g = await read($, game)
80 if (g === 'sudoku' && !(await read($, sudoku))) await setSudoku($, () => newSudoku())
81 if (g === 'tts' && !(await read($, tts))) void startTts($).catch(() => {})
82 return $.ui.open({ id: PANE, title: TITLE[g], focus: true, closeOnEscape: true, rows: ROWS[g] })
83}
84
85export const register: Register = on => {
86 on('session.start', async ($, e, next) => {
87 await $.command.register({ name: 'sudoku', description: 'Play Sudoku in a pane' })
88 await $.command.register({ name: 'tts', description: 'Main teka-teki silang (TTS) di pane' })
89 await $.command.register({ name: 'snake', description: 'Play Nokia-style Snake in a pane' })
90 await $.command.register({
91 name: 'puzzle',
92 description: 'Puzzle while Claude cooks: on | off | sudoku | tts | snake | ai on | ai off',
93 argumentHint: 'on | off | sudoku | tts | snake | ai on | ai off',
94 })
95 await update($, cooking, () => false)
96 await update($, ttsLoading, () => false)
97 // put back what an earlier session saved
98 const was = async (key: string) => $.store.get(key).catch(() => undefined)
99 const savedGame = await was('game')
100 if (typeof savedGame === 'string' && isGame(savedGame)) await update($, game, () => savedGame)
101 const savedAuto = await was('auto')
102 if (typeof savedAuto === 'boolean') await update($, auto, () => savedAuto)
103 const savedAi = await was('ttsAi')
104 if (typeof savedAi === 'boolean') await update($, ttsAi, () => savedAi)
105 const savedBest = await was('snakeBest')
106 if (typeof savedBest === 'number') await update($, snakeBest, cur => Math.max(cur, savedBest))
107 const savedSudoku = await was('sudoku')
108 if (savedSudoku && !(await read($, sudoku))) await update($, sudoku, () => savedSudoku as Sudoku)
109 const savedTts = await was('tts')
110 if (savedTts && !(await read($, tts))) await update($, tts, () => savedTts as Tts)
111
112 return next(e)
113 })
114
115 on('command.run', { command: 'sudoku' }, async $ => {
116 await openPane($, 'sudoku')
117 return { text: 'Sudoku opened. w/a/s/d move, 1-9 fill, 0 erase, Esc to leave.' }
118 })
119
120 on('command.run', { command: 'tts' }, async $ => {
121 await openPane($, 'tts')
122 return { text: 'TTS dibuka. Ketik jawaban lalu Enter; Tab untuk pilih soal.' }
123 })
124
125 on('command.run', { command: 'snake' }, async $ => {
126 await openPane($, 'snake')
127 return { text: 'Snake opened. Click the board, then arrows/WASD. Space pauses, Esc leaves.' }
128 })
129
130 // the snake board posts a new best score when a game ends
131 on('ui.message', { requestId: PANE }, async ($, e, next) => {
132 const best = (e.data as { best?: unknown } | null)?.best
133 if (typeof best === 'number') await setBest($, cur => Math.max(cur, best))
134 return next(e)
135 })
136
137 on('command.run', { command: 'puzzle' }, async ($, e) => {
138 const arg = e.args.trim().toLowerCase()
139 if (arg === 'on' || arg === 'off') {
140 await setAuto($, () => arg === 'on')
141 return { text: `Auto-open while Claude cooks: ${arg}` }
142 }
143 if (arg === 'ai on' || arg === 'ai off') {
144 await setAi($, () => arg === 'ai on')
145 return { text: `TTS words written by Claude: ${arg.slice(3)}` }
146 }
147 if (isGame(arg)) {
148 await setGame($, () => arg)
149 return { text: `Auto-open game: ${TITLE[arg]}` }
150 }
151 const [isOn, g] = [await read($, auto), await read($, game)]
152 return { text: `Auto-open: ${isOn ? 'on' : 'off'}, game: ${TITLE[g]}. TTS by Claude: ${(await read($, ttsAi)) ? 'on' : 'off'}. Usage: /puzzle on|off|sudoku|tts|snake|ai on|ai off` }
153 })
154
155 // Claude starts cooking: open the puzzle.
156 on('prompt.submit', async ($, e, next) => {
157 const result = await next(e)
158 if (e.origin.kind === 'composer' && !e.text.startsWith('/') && !('drop' in result)) {
159 // never let the game get in the way of the prompt
160 try {
161 await update($, cooking, () => true)
162 // auto off: stay quiet; the games still open by command
163 if (await read($, auto)) {
164 $.ui.status('🍳 Claude is cooking… puzzle time')
165 void openPane($).catch(() => {})
166 }
167 } catch {}
168 }
169 return result
170 })
171
172 // Claude is done: tell the player.
173 on('turn.complete', async ($, e, next) => {
174 if (await read($, cooking)) {
175 await update($, cooking, () => false)
176 $.ui.status(undefined)
177 const isUp = (await $.ui.panes()).some(p => p.id === PANE)
178 if (isUp) $.ui.toast('🍽️ Claude is done! Esc to return to the prompt (your game is saved)')
179 }
180 return next(e)
181 })
182
183 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
184 const els = $.ui.resolve(e)
185 const { Box, Text, Button } = els
186 const Input = 'Input' in els ? els.Input : undefined
187 const g = await read($, game)
188 const isCooking = await read($, cooking)
189
190 const banner = (
191 <Text color={isCooking ? 'warning' : 'success'}>
192 {isCooking ? '🍳 Claude is cooking…' : '✅ Claude is done — Esc to go back'}
193 </Text>
194 )
195 const switcher = (
196 <Button
197 key="switch"
198 plain
199 hotkey="g"
200 label={g === 'sudoku' ? 'switch to TTS' : 'switch to Sudoku'}
201 onPress={() => openPane($, g === 'sudoku' ? 'tts' : 'sudoku')}
202 />
203 )
204
205 if (g === 'snake') {
206 const Client = 'Client' in els ? els.Client : undefined
207 return (
208 <Box flexDirection="column">
209 {banner}
210 {Client ? (
211 <Client key="snake" module="./snake.tsx" props={{ best: await read($, snakeBest) }} />
212 ) : (
213 <Text dimColor>Snake needs the terminal or the desktop app.</Text>
214 )}
215 <Box flexDirection="row">
216 <Button key="tosudoku" label="Sudoku" onPress={() => openPane($, 'sudoku')} />
217 <Text> </Text>
218 <Button key="totts" label="TTS" onPress={() => openPane($, 'tts')} />
219 </Box>
220 </Box>
221 )
222 }
223
224 if (g === 'sudoku') {
225 const s = await read($, sudoku)
226 if (!s) {
227 return (
228 <Box flexDirection="column">
229 {banner}
230 <Button key="start" autoFocus hotkey="n" label="Start Sudoku" onPress={() => setSudoku($, () => newSudoku())} />
231 </Box>
232 )
233 }
234 const set = (fn: (s: Sudoku) => Sudoku) => setSudoku($, cur => (cur ? fn(cur) : cur))
235 const move = (dr: number, dc: number) => set(x => ({ ...x, cursor: moveCursor(x.cursor, dr, dc) }))
236 const put = (d: number) =>
237 set(x => {
238 if (x.puzzle[x.cursor] !== 0) return x
239 const board = [...x.board]
240 board[x.cursor] = d
241 return { ...x, board }
242 })
243 const isDone = isSudokuSolved(s)
244 const filled = s.board.filter(Boolean).length
245
246 const rule = (l: string, m: string, r: string) => (
247 <Text dimColor>{l + '───────' + m + '───────' + m + '───────' + r}</Text>
248 )
249 const row = (r: number) => (
250 <Box key={`r${r}`} flexDirection="row">
251 {[...Array(9).keys()].flatMap(c => {
252 const i = r * 9 + c
253 const d = s.board[i]
254 const isGiven = s.puzzle[i] !== 0
255 const isWrong = !isGiven && d !== 0 && d !== s.solution[i]
256 const parts = []
257 if (c % 3 === 0) parts.push(<Text dimColor>{c === 0 ? '│' : ' │'}</Text>)
258 parts.push(<Text> </Text>)
259 parts.push(
260 <Text
261 bold={isGiven}
262 color={isWrong ? 'error' : isGiven ? undefined : 'suggestion'}
263 inverse={i === s.cursor && !isDone}
264 >
265 {d === 0 ? '·' : String(d)}
266 </Text>,
267 )
268 if (c === 8) parts.push(<Text dimColor> │</Text>)
269 return parts
270 })}
271 </Box>
272 )
273
274 return (
275 <Box flexDirection="column">
276 {banner}
277 <Box flexDirection="row">
278 <Box flexDirection="column" marginRight={2}>
279 {rule('┌', '┬', '┐')}
280 {row(0)}{row(1)}{row(2)}
281 {rule('├', '┼', '┤')}
282 {row(3)}{row(4)}{row(5)}
283 {rule('├', '┼', '┤')}
284 {row(6)}{row(7)}{row(8)}
285 {rule('└', '┴', '┘')}
286 </Box>
287 <Box flexDirection="column">
288 {isDone ? <Text color="success" bold>🎉 Solved!</Text> : <Text dimColor>{filled}/81 filled</Text>}
289 <Text> </Text>
290 <Box flexDirection="row">
291 <Button key="up" plain hotkey="w" label="↑" onPress={() => move(-1, 0)} />
292 <Text> </Text>
293 <Button key="left" plain hotkey="a" label="←" onPress={() => move(0, -1)} />
294 <Text> </Text>
295 <Button key="down" plain hotkey="s" label="↓" onPress={() => move(1, 0)} />
296 <Text> </Text>
297 <Button key="right" plain hotkey="d" label="→" onPress={() => move(0, 1)} />
298 </Box>
299 <Box flexDirection="row">
300 {[1, 2, 3, 4, 5, 6, 7, 8, 9].map(d => (
301 <Button key={`n${d}`} hotkey={String(d)} label={String(d)} onPress={() => put(d)} />
302 ))}
303 </Box>
304 <Button key="erase" plain hotkey="0" label="erase" onPress={() => put(0)} />
305 <Button key="new" plain hotkey="n" label="new puzzle" onPress={() => setSudoku($, () => newSudoku())} />
306 {switcher}
307 <Button key="tosnake" plain hotkey="k" label="play Snake" onPress={() => openPane($, 'snake')} />
308 <Text> </Text>
309 <Text dimColor>Press the keys shown.</Text>
310 <Text dimColor>Ctrl+X Tab focuses the pane.</Text>
311 </Box>
312 </Box>
313 </Box>
314 )
315 }
316
317 // ---- TTS ----
318 const t = await read($, tts)
319 if (!t) {
320 return (
321 <Box flexDirection="column">
322 {banner}
323 {(await read($, ttsLoading)) ? (
324 <Text color="claude">✍️ Claude lagi bikin TTS baru…</Text>
325 ) : (
326 <Button key="start" autoFocus hotkey="n" label="Mulai TTS" onPress={() => startTts($)} />
327 )}
328 </Box>
329 )
330 }
331 const text = await read($, draft)
332 const sel = t.words[t.sel]
333
334 const shown = new Map<string, string>()
335 const starts = new Map<string, number>()
336 const inSel = new Set<string>()
337 for (const w of t.words) {
338 wordCells(w).forEach((x, k) => {
339 const id = `${x.r},${x.c}`
340 if (w.solved || k < w.hints) shown.set(id, x.ch)
341 if (!shown.has(id)) shown.set(id, '')
342 if (k === 0) starts.set(id, w.n)
343 if (w === sel) inSel.add(id)
344 })
345 }
346 const isAllDone = t.words.every(w => w.solved)
347 const isMaking = await read($, ttsLoading)
348
349 const grid = [...Array(t.rows).keys()].map(r => (
350 <Box key={`g${r}`} flexDirection="row">
351 {[...Array(t.cols).keys()].map(c => {
352 const id = `${r},${c}`
353 if (!shown.has(id)) return <Text>{' '}</Text>
354 const letter = shown.get(id)
355 const n = starts.get(id)
356 const glyph = letter || (n !== undefined && n < 10 ? String(n) : '·')
357 return (
358 <Text
359 color={letter ? 'success' : undefined}
360 dimColor={!letter && n === undefined}
361 inverse={inSel.has(id) && !isAllDone}
362 >
363 {' ' + glyph}
364 </Text>
365 )
366 })}
367 </Box>
368 ))
369
370 const clueButton = (i: number) => {
371 const w = t.words[i]
372 return (
373 <Button
374 key={`clue${i}`}
375 plain
376 dimColor={w.solved}
377 label={`${i === t.sel ? '▶' : ' '} ${w.n}. ${w.clue} (${w.answer.length})${w.solved ? ' ✓' : ''}`}
378 onPress={() => setTts($, cur => (cur ? { ...cur, sel: i } : cur))}
379 />
380 )
381 }
382 const across = t.words.map((w, i) => (w.dir === 'across' ? i : -1)).filter(i => i >= 0)
383 const down = t.words.map((w, i) => (w.dir === 'down' ? i : -1)).filter(i => i >= 0)
384
385 const submit = async (value: string) => {
386 await update($, draft, () => '')
387 const cur = await read($, tts)
388 if (!cur) return
389 if (value.trim() === '') {
390 const next = nextUnsolved(cur, cur.sel)
391 if (next >= 0) await setTts($, () => ({ ...cur, sel: next }))
392 return
393 }
394 const { tts: after, isRight } = answerTts(cur, value)
395 if (isRight) await setTts($, () => after)
396 else $.ui.toast(`❌ "${value}" belum tepat, coba lagi`)
397 }
398
399 return (
400 <Box flexDirection="column">
401 {banner}
402 <Text dimColor>{t.theme ? `Tema: ${t.theme} (dibuat Claude)` : 'Soal bawaan'}</Text>
403 {isMaking && <Text color="claude">✍️ Claude lagi bikin TTS baru…</Text>}
404 <Box flexDirection="row">
405 <Box flexDirection="column" marginRight={3}>
406 {grid}
407 </Box>
408 <Box flexDirection="column" flexGrow={1}>
409 <Text bold>Mendatar</Text>
410 {across.map(clueButton)}
411 <Text bold>Menurun</Text>
412 {down.map(clueButton)}
413 </Box>
414 </Box>
415 {isAllDone ? (
416 <Text color="success" bold>🎉 Semua terjawab!</Text>
417 ) : !Input ? (
418 <Text dimColor>Typing answers needs the terminal or desktop.</Text>
419 ) : (
420 <Input
421 key="answer"
422 autoFocus
423 label={`${sel.n} ${sel.dir === 'across' ? 'mendatar' : 'menurun'}: `}
424 placeholder="ketik jawaban, Enter (kosong = soal berikutnya)"
425 value={text}
426 submitLabel="jawab"
427 onInput={(value: string) => update($, draft, () => value)}
428 onSubmit={(value: string) => void submit(value)}
429 />
430 )}
431 <Box flexDirection="row">
432 <Button key="hint" label="petunjuk" onPress={() => setTts($, cur => (cur ? hintTts(cur) : cur))} />
433 <Text> </Text>
434 <Button key="newtts" label="TTS baru" onPress={() => startTts($)} />
435 <Text> </Text>
436 <Button key="switch" label="Sudoku" onPress={() => openPane($, 'sudoku')} />
437 <Text> </Text>
438 <Button key="tosnake" label="Snake" onPress={() => openPane($, 'snake')} />
439 </Box>
440 </Box>
441 )
442 })
443}
444hooks/games.ts 306 lines1import type { Sudoku, Tts, Word } from '../types'
2
3const shuffle = <T>(list: T[]): T[] => {
4 const out = [...list]
5 for (let i = out.length - 1; i > 0; i--) {
6 const j = Math.floor(Math.random() * (i + 1))
7 ;[out[i], out[j]] = [out[j], out[i]]
8 }
9 return out
10}
11
12// ---------- Sudoku ----------
13
14const canPlace = (b: number[], i: number, d: number): boolean => {
15 const r = Math.floor(i / 9)
16 const c = i % 9
17 const br = r - (r % 3)
18 const bc = c - (c % 3)
19 for (let k = 0; k < 9; k++) {
20 if (b[r * 9 + k] === d || b[k * 9 + c] === d) return false
21 if (b[(br + Math.floor(k / 3)) * 9 + bc + (k % 3)] === d) return false
22 }
23 return true
24}
25
26const fill = (b: number[]): boolean => {
27 const i = b.indexOf(0)
28 if (i < 0) return true
29 for (const d of shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9])) {
30 if (canPlace(b, i, d)) {
31 b[i] = d
32 if (fill(b)) return true
33 }
34 }
35 b[i] = 0
36 return false
37}
38
39export const countSolutions = (b: number[], limit = 2): number => {
40 const i = b.indexOf(0)
41 if (i < 0) return 1
42 let count = 0
43 for (let d = 1; d <= 9 && count < limit; d++) {
44 if (canPlace(b, i, d)) {
45 b[i] = d
46 count += countSolutions(b, limit - count)
47 }
48 }
49 b[i] = 0
50 return count
51}
52
53/** A puzzle with exactly one solution and up to `holes` empty cells. */
54export const newSudoku = (holes = 46): Sudoku => {
55 const solution = Array<number>(81).fill(0)
56 fill(solution)
57 const puzzle = [...solution]
58 let removed = 0
59 for (const i of shuffle([...Array(81).keys()])) {
60 if (removed >= holes) break
61 const keep = puzzle[i]
62 puzzle[i] = 0
63 if (countSolutions([...puzzle]) === 1) removed++
64 else puzzle[i] = keep
65 }
66 return { puzzle, solution, board: [...puzzle], cursor: puzzle.indexOf(0) }
67}
68
69export const isSudokuSolved = (s: Sudoku): boolean =>
70 s.board.every((d, i) => d === s.solution[i])
71
72export const moveCursor = (cursor: number, dr: number, dc: number): number => {
73 const r = (Math.floor(cursor / 9) + dr + 9) % 9
74 const c = ((cursor % 9) + dc + 9) % 9
75 return r * 9 + c
76}
77
78// ---------- TTS (teka-teki silang) ----------
79
80export type Entry = { answer: string; clue: string }
81
82export const BANK: Entry[] = [
83 { answer: 'KUCING', clue: 'Hewan peliharaan yang suka mengeong' },
84 { answer: 'NASI', clue: 'Makanan pokok orang Indonesia' },
85 { answer: 'GORENG', clue: 'Cara memasak dengan minyak panas' },
86 { answer: 'SAMBAL', clue: 'Pelengkap pedas dari cabai' },
87 { answer: 'RENDANG', clue: 'Masakan daging khas Minang' },
88 { answer: 'SATE', clue: 'Daging tusuk yang dibakar' },
89 { answer: 'BAKSO', clue: 'Bola daging berkuah' },
90 { answer: 'TEMPE', clue: 'Olahan kedelai fermentasi' },
91 { answer: 'KOPI', clue: 'Minuman hitam berkafein' },
92 { answer: 'TEH', clue: 'Minuman dari daun yang diseduh' },
93 { answer: 'GARAM', clue: 'Bumbu asin dari laut' },
94 { answer: 'GULA', clue: 'Pemanis dari tebu' },
95 { answer: 'WAJAN', clue: 'Alat untuk menggoreng' },
96 { answer: 'DAPUR', clue: 'Ruang tempat memasak' },
97 { answer: 'KOKI', clue: 'Juru masak profesional' },
98 { answer: 'JAKARTA', clue: 'Ibu kota Indonesia (lama)' },
99 { answer: 'BALI', clue: 'Pulau Dewata' },
100 { answer: 'MONAS', clue: 'Tugu ikonik di Jakarta' },
101 { answer: 'GARUDA', clue: 'Lambang negara Indonesia' },
102 { answer: 'BATIK', clue: 'Kain bermotif warisan budaya' },
103 { answer: 'ANGKLUNG', clue: 'Alat musik bambu dari Jawa Barat' },
104 { answer: 'GAMELAN', clue: 'Ansambel musik tradisional Jawa' },
105 { answer: 'WAYANG', clue: 'Pertunjukan boneka kulit' },
106 { answer: 'KOMODO', clue: 'Kadal raksasa dari NTT' },
107 { answer: 'GAJAH', clue: 'Hewan berbelalai' },
108 { answer: 'HARIMAU', clue: 'Kucing besar belang dari Sumatra' },
109 { answer: 'BURUNG', clue: 'Hewan bersayap dan berbulu' },
110 { answer: 'IKAN', clue: 'Hewan yang bernapas dengan insang' },
111 { answer: 'LAUT', clue: 'Perairan luas dan asin' },
112 { answer: 'GUNUNG', clue: 'Tanah yang menjulang tinggi' },
113 { answer: 'HUJAN', clue: 'Air yang turun dari langit' },
114 { answer: 'MATAHARI', clue: 'Bintang pusat tata surya' },
115 { answer: 'BULAN', clue: 'Satelit alami bumi' },
116 { answer: 'BINTANG', clue: 'Berkelip di langit malam' },
117 { answer: 'PELANGI', clue: 'Lengkung warna-warni setelah hujan' },
118 { answer: 'SEKOLAH', clue: 'Tempat belajar murid' },
119 { answer: 'GURU', clue: 'Pahlawan tanpa tanda jasa' },
120 { answer: 'BUKU', clue: 'Jendela dunia' },
121 { answer: 'PENSIL', clue: 'Alat tulis dari grafit' },
122 { answer: 'KOMPUTER', clue: 'Mesin hitung elektronik' },
123 { answer: 'KODE', clue: 'Yang ditulis programmer' },
124 { answer: 'BUG', clue: 'Kesalahan dalam program' },
125 { answer: 'MERDEKA', clue: 'Bebas dari penjajahan' },
126 { answer: 'RUPIAH', clue: 'Mata uang Indonesia' },
127 { answer: 'SEPEDA', clue: 'Kendaraan roda dua dikayuh' },
128 { answer: 'KERETA', clue: 'Kendaraan di atas rel' },
129 { answer: 'PANTAI', clue: 'Tepi laut berpasir' },
130 { answer: 'DURIAN', clue: 'Raja buah berduri' },
131 { answer: 'MANGGA', clue: 'Buah manis, harum manis salah satunya' },
132 { answer: 'PISANG', clue: 'Buah kuning kesukaan monyet' },
133]
134
135type Placed = { r: number; c: number; dir: Word['dir']; answer: string; clue: string }
136
137const MAX = 15
138
139const cellsOf = (p: Placed) =>
140 [...p.answer].map((ch, k) => ({
141 r: p.r + (p.dir === 'down' ? k : 0),
142 c: p.c + (p.dir === 'across' ? k : 0),
143 ch,
144 }))
145
146/** Letter at each cell, keyed "r,c". */
147const gridOf = (placed: Placed[]) => {
148 const grid = new Map<string, string>()
149 for (const p of placed) for (const x of cellsOf(p)) grid.set(`${x.r},${x.c}`, x.ch)
150 return grid
151}
152
153const fits = (placed: Placed[], grid: Map<string, string>, p: Placed): number => {
154 const at = (r: number, c: number) => grid.get(`${r},${c}`)
155 const [dr, dc] = p.dir === 'across' ? [0, 1] : [1, 0]
156 const len = p.answer.length
157 if (at(p.r - dr, p.c - dc) || at(p.r + dr * len, p.c + dc * len)) return -1
158 let crossings = 0
159 for (const x of cellsOf(p)) {
160 const here = at(x.r, x.c)
161 if (here) {
162 if (here !== x.ch) return -1
163 crossings++
164 } else if (at(x.r + dc, x.c + dr) || at(x.r - dc, x.c - dr)) {
165 return -1
166 }
167 }
168 if (crossings === 0 || crossings === len) return -1
169 // a crossing must cross a word of the other direction
170 for (const q of placed) {
171 if (q.dir !== p.dir) continue
172 for (const x of cellsOf(q)) if (cellsOf(p).some(y => y.r === x.r && y.c === x.c)) return -1
173 }
174 // keep within MAX×MAX
175 const all = [...grid.keys()].map(k => k.split(',').map(Number))
176 for (const x of cellsOf(p)) all.push([x.r, x.c])
177 const rs = all.map(a => a[0])
178 const cs = all.map(a => a[1])
179 if (Math.max(...rs) - Math.min(...rs) >= MAX || Math.max(...cs) - Math.min(...cs) >= MAX) return -1
180 return crossings
181}
182
183export const newTts = (count = 8, bank: Entry[] = BANK): Tts => {
184 for (let attempt = 0; ; attempt++) {
185 const pool = shuffle(bank)
186 const first = pool.shift()!
187 const placed: Placed[] = [{ r: 0, c: 0, dir: 'across', ...first }]
188 for (const w of pool) {
189 if (placed.length >= count) break
190 const grid = gridOf(placed)
191 const options: { p: Placed; score: number }[] = []
192 for (const [key, ch] of grid) {
193 const [r, c] = key.split(',').map(Number)
194 for (let k = 0; k < w.answer.length; k++) {
195 if (w.answer[k] !== ch) continue
196 for (const dir of ['across', 'down'] as const) {
197 const p: Placed = {
198 r: dir === 'down' ? r - k : r,
199 c: dir === 'across' ? c - k : c,
200 dir,
201 ...w,
202 }
203 const score = fits(placed, grid, p)
204 if (score > 0) options.push({ p, score })
205 }
206 }
207 }
208 if (options.length === 0) continue
209 const best = Math.max(...options.map(o => o.score))
210 const top = options.filter(o => o.score === best)
211 placed.push(top[Math.floor(Math.random() * top.length)].p)
212 }
213 if (placed.length < Math.min(count, 5) && attempt < 20) continue
214 return layout(placed)
215 }
216}
217
218const layout = (placed: Placed[]): Tts => {
219 const minR = Math.min(...placed.flatMap(p => cellsOf(p).map(x => x.r)))
220 const minC = Math.min(...placed.flatMap(p => cellsOf(p).map(x => x.c)))
221 const moved = placed.map(p => ({ ...p, r: p.r - minR, c: p.c - minC }))
222 const starts = [...new Set(moved.map(p => p.r * 100 + p.c))].sort((a, b) => a - b)
223 const words: Word[] = moved
224 .map(p => ({ ...p, n: starts.indexOf(p.r * 100 + p.c) + 1, solved: false, hints: 0 }))
225 .sort((a, b) => a.n - b.n || (a.dir === 'across' ? -1 : 1))
226 const rows = Math.max(...words.flatMap(w => cellsOf(w).map(x => x.r))) + 1
227 const cols = Math.max(...words.flatMap(w => cellsOf(w).map(x => x.c))) + 1
228 return { rows, cols, words, sel: 0 }
229}
230
231export const wordCells = (w: Word) => cellsOf(w)
232
233export const normalize = (text: string) => text.toUpperCase().replace(/[^A-Z]/g, '')
234
235/** Index of the next unsolved word after `from`, or -1 when all are solved. */
236export const nextUnsolved = (t: Tts, from: number): number => {
237 for (let k = 1; k <= t.words.length; k++) {
238 const i = (from + k) % t.words.length
239 if (!t.words[i].solved) return i
240 }
241 return -1
242}
243
244/** Answer the selected word; returns the new state and whether it was right. */
245export const answerTts = (t: Tts, text: string): { tts: Tts; isRight: boolean } => {
246 const w = t.words[t.sel]
247 if (!w || normalize(text) !== w.answer) return { tts: t, isRight: false }
248 const words = t.words.map((x, i) => (i === t.sel ? { ...x, solved: true } : x))
249 const solved = { ...t, words }
250 const next = nextUnsolved(solved, t.sel)
251 return { tts: { ...solved, sel: next < 0 ? t.sel : next }, isRight: true }
252}
253
254export const hintTts = (t: Tts): Tts => ({
255 ...t,
256 words: t.words.map((w, i) => {
257 if (i !== t.sel || w.solved) return w
258 const hints = w.hints + 1
259 return { ...w, hints, solved: hints >= w.answer.length }
260 }),
261})
262
263// ---------- TTS words written by Claude ----------
264
265const THEMES = [
266 'makanan & minuman', 'hewan', 'alam & cuaca', 'budaya Indonesia', 'kota & tempat di Indonesia',
267 'teknologi & pemrograman', 'olahraga', 'pekerjaan', 'rumah & benda sehari-hari', 'musik & film',
268 'sains', 'tubuh & kesehatan', 'transportasi', 'sekolah', 'buah & sayur', 'sejarah Indonesia',
269]
270
271export const pickTheme = () => THEMES[Math.floor(Math.random() * THEMES.length)]!
272
273export const ttsPrompt = (theme: string, avoid: string[]) =>
274 `Buat 16 kata untuk teka-teki silang (TTS) berbahasa Indonesia dengan tema "${theme}".
275Aturan:
276- Jawaban: satu kata bahasa Indonesia, huruf A-Z saja, 3 sampai 9 huruf, tanpa spasi atau tanda hubung.
277- Petunjuk: singkat (maksimal 8 kata), jelas, tidak menyebut jawabannya.
278- Campur kata pendek dan panjang, dengan banyak huruf vokal yang umum agar mudah disilangkan.
279${avoid.length ? `- Jangan pakai kata-kata ini: ${avoid.join(', ')}\n` : ''}Balas HANYA dengan array JSON, tanpa teks lain:
280[{"answer":"KUCING","clue":"Hewan peliharaan yang suka mengeong"}]`
281
282/** The entries a model reply holds, the bad ones dropped. */
283export const parseEntries = (text: string): Entry[] => {
284 const start = text.indexOf('[')
285 const end = text.lastIndexOf(']')
286 if (start < 0 || end <= start) return []
287 let raw: unknown
288 try {
289 raw = JSON.parse(text.slice(start, end + 1))
290 } catch {
291 return []
292 }
293 if (!Array.isArray(raw)) return []
294 const seen = new Set<string>()
295 const out: Entry[] = []
296 for (const item of raw) {
297 const answer = typeof item?.answer === 'string' ? item.answer.trim().toUpperCase() : ''
298 const clue = typeof item?.clue === 'string' ? item.clue.trim().slice(0, 80) : ''
299 if (!/^[A-Z]{3,9}$/.test(answer) || !clue || seen.has(answer)) continue
300 if (clue.toUpperCase().includes(answer)) continue
301 seen.add(answer)
302 out.push({ answer, clue })
303 }
304 return out
305}
306hooks/snake.tsx 161 lines1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3// Nokia 3310 screen colors
4const LCD = '#c7f0d8'
5const INK = '#43523d'
6
7export const W = 20
8export const H = 10
9const TICK = 40 // ms per frame; the snake moves every few frames
10
11type Dir = 'up' | 'down' | 'left' | 'right'
12type Cell = { x: number; y: number }
13
14export type SnakeState = {
15 snake: Cell[] // head first
16 dir: Dir
17 queue: Dir[] // turns typed between moves
18 food: Cell
19 score: number
20 mode: 'ready' | 'playing' | 'paused' | 'over'
21 live: { frames: number; best: number } // mutated in place: no redraw needed
22}
23
24export type SnakeProps = { best: number }
25
26const STEP: Record<Dir, Cell> = {
27 up: { x: 0, y: -1 },
28 down: { x: 0, y: 1 },
29 left: { x: -1, y: 0 },
30 right: { x: 1, y: 0 },
31}
32const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
33const KEYS: Record<string, Dir> = {
34 up: 'up', down: 'down', left: 'left', right: 'right',
35 w: 'up', s: 'down', a: 'left', d: 'right',
36 k: 'up', j: 'down', h: 'left', l: 'right',
37}
38
39const randomFood = (snake: Cell[]): Cell => {
40 for (;;) {
41 const cell = { x: Math.floor(Math.random() * W), y: Math.floor(Math.random() * H) }
42 if (!snake.some(s => s.x === cell.x && s.y === cell.y)) return cell
43 }
44}
45
46export const newGame = (): SnakeState => {
47 const snake = [{ x: 6, y: 5 }, { x: 5, y: 5 }, { x: 4, y: 5 }]
48 return { snake, dir: 'right', queue: [], food: randomFood(snake), score: 0, mode: 'ready', live: { frames: 0, best: 0 } }
49}
50
51/** Frames between moves: 4 (160ms) at the start, 2 (80ms) at full speed. */
52const framesPerMove = (score: number) => Math.max(2, 4 - Math.floor(score / 5))
53
54/** One move of the snake. Walls wrap around, as in Snake II. */
55export const step = (s: SnakeState): SnakeState => {
56 const [turn, ...queue] = s.queue
57 const dir = turn ?? s.dir
58 const head = s.snake[0]!
59 const next = {
60 x: (head.x + STEP[dir].x + W) % W,
61 y: (head.y + STEP[dir].y + H) % H,
62 }
63 const eats = next.x === s.food.x && next.y === s.food.y
64 const body = eats ? s.snake : s.snake.slice(0, -1)
65 if (body.some(c => c.x === next.x && c.y === next.y)) {
66 return { ...s, dir, queue: [], mode: 'over' }
67 }
68 const snake = [next, ...body]
69 return {
70 ...s,
71 snake,
72 dir,
73 queue,
74 food: eats ? randomFood(snake) : s.food,
75 score: eats ? s.score + 1 : s.score,
76 }
77}
78
79/** What a key does to the game. */
80export const press = (s: SnakeState, e: ClientKeyEvent): SnakeState => {
81 const key = e.key.toLowerCase()
82 if (key === 'r' || ((key === 'space' || key === ' ' || key === 'return') && s.mode === 'over')) {
83 return { ...newGame(), mode: 'playing', live: s.live }
84 }
85 if (key === 'space' || key === ' ' || key === 'p' || key === 'return') {
86 return { ...s, mode: s.mode === 'playing' ? 'paused' : s.mode === 'over' ? s.mode : 'playing' }
87 }
88 const dir = KEYS[key]
89 if (!dir || s.mode === 'over') return s
90 const last = s.queue[s.queue.length - 1] ?? s.dir
91 if (dir === last || dir === OPPOSITE[last] || s.queue.length >= 2) {
92 return s.mode === 'playing' ? s : { ...s, mode: 'playing' }
93 }
94 return { ...s, queue: [...s.queue, dir], mode: 'playing' }
95}
96
97const Snake: ClientModule<SnakeProps, SnakeState> = (props, surface) => {
98 const { Box, Text } = surface.elements
99 const s = surface.state ?? newGame()
100 s.live.best = props.best
101
102 if (surface.state === undefined) {
103 surface.setState(s)
104 surface.onKey(e => {
105 const cur = surface.state ?? s
106 const after = press(cur, e)
107 if (after !== cur) surface.setState(after)
108 })
109 surface.onPointer(e => {
110 const cur = surface.state ?? s
111 if (e.type === 'down' && cur.mode !== 'playing') {
112 surface.setState(cur.mode === 'over' ? { ...newGame(), mode: 'playing', live: cur.live } : { ...cur, mode: 'playing' })
113 }
114 })
115 surface.every(TICK, () => {
116 const cur = surface.state
117 if (!cur || cur.mode !== 'playing') return
118 cur.live.frames += 1
119 if (cur.live.frames % framesPerMove(cur.score) !== 0) return
120 const after = step(cur)
121 surface.setState(after)
122 if (after.mode === 'over' && after.score > cur.live.best) surface.post({ best: after.score })
123 })
124 }
125
126 const rows: string[] = []
127 for (let y = 0; y < H; y++) {
128 let line = ''
129 for (let x = 0; x < W; x++) {
130 const isHead = s.snake[0]!.x === x && s.snake[0]!.y === y
131 const isBody = s.snake.some(c => c.x === x && c.y === y)
132 const isFood = s.food.x === x && s.food.y === y
133 line += isHead ? '▓▓' : isBody ? '██' : isFood ? '◆ ' : ' '
134 }
135 rows.push(line)
136 }
137
138 const status =
139 s.mode === 'ready' ? 'Arrows/WASD to start · click here first'
140 : s.mode === 'paused' ? 'Paused · space to resume'
141 : s.mode === 'over' ? `Game over! Score ${s.score} · space to restart`
142 : 'Space pauses · r restarts'
143
144 return (
145 <Box flexDirection="column">
146 <Box flexDirection="row" width={W * 2 + 2} justifyContent="space-between">
147 <Text bold>{String(s.score).padStart(4, '0')}</Text>
148 <Text dimColor>best {Math.max(props.best, s.mode === 'over' ? s.score : 0)}</Text>
149 </Box>
150 <Box flexDirection="column" borderStyle="bold" borderColor={INK} width={W * 2 + 2}>
151 {rows.map(line => (
152 <Text color={INK} backgroundColor={LCD}>{line}</Text>
153 ))}
154 </Box>
155 <Text dimColor={s.mode !== 'over'} color={s.mode === 'over' ? 'error' : undefined}>{status}</Text>
156 </Box>
157 )
158}
159
160export default Snake
161types/index.d.ts 38 lines1export type Game = 'sudoku' | 'tts' | 'snake'
2
3export type Sudoku = {
4 puzzle: number[]
5 solution: number[]
6 board: number[]
7 cursor: number
8}
9
10export type Word = {
11 n: number
12 r: number
13 c: number
14 dir: 'across' | 'down'
15 answer: string
16 clue: string
17 solved: boolean
18 hints: number
19}
20
21export type Tts = { rows: number; cols: number; words: Word[]; sel: number; theme?: string }
22
23declare module 'claude-code' {
24 interface PluginState {
25 'cooking-puzzles': {
26 game: Game
27 sudoku: Sudoku | null
28 tts: Tts | null
29 auto: boolean
30 cooking: boolean
31 draft: string
32 snakeBest: number
33 ttsAi: boolean
34 ttsLoading: boolean
35 }
36 }
37}
38