Play a Wordle-style game in a pane while Claude generates a response.

hooks/register.js 117 lines1import { atom, read, update } from 'claude-code'
2
3import { FALLBACK_NOTE, renderBoard } from './lib/board-view.js'
4import { createGame, submitGuess, WORD_LENGTH } from './lib/game-engine.js'
5import { isValidGuess, resolveWord } from './lib/word-source.js'
6
7const PANE = 'wordle'
8
9const game = atom({ plugin: 'wordle-mod', key: 'game' }, null)
10const draft = atom({ plugin: 'wordle-mod', key: 'draft' }, '')
11const puzzle = atom({ plugin: 'wordle-mod', key: 'puzzle' }, null)
12
13// A plugin has one hooks module and the engine's `$` can't cross an import, so
14// everything that touches `$` lives here; lib/ is pure and takes closures.
15const makeIo = $ => ({
16 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
17 fetch: (url, init) => $.http.fetch(url, init),
18 sleep: ms => $.clock.sleep(ms),
19 readData: file => $.fs.read(`${$.plugin.root}/data/${file}`),
20})
21
22const today = async $ => {
23 const d = new Date(await $.clock.now())
24 const pad = n => String(n).padStart(2, '0')
25
26 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
27}
28
29let isLoading = false
30
31/** Resolves today's word and starts a game. Runs from a timer, never from a draw. */
32const startToday = async $ => {
33 try {
34 const date = await today($)
35 const { solution, source } = await resolveWord(makeIo($), date)
36 await update($, game, () => createGame(solution))
37 await update($, draft, () => '')
38 await update($, puzzle, () => ({ date, source }))
39 } catch {
40 $.ui.toast('Could not start a puzzle.')
41 } finally {
42 isLoading = false
43 }
44}
45
46const typeLetter = async ($, letter) => {
47 const g = await read($, game)
48 if (!g || g.status !== 'playing') return
49 await update($, draft, d => (d.length < WORD_LENGTH ? d + letter : d))
50}
51
52const backspace = async $ => {
53 await update($, draft, d => d.slice(0, -1))
54}
55
56/** The typing field's text, reduced to the draft: lowercase letters, at most five. */
57const setDraft = async ($, text) => {
58 const g = await read($, game)
59 if (!g || g.status !== 'playing') return
60 const letters = String(text).toLowerCase().replace(/[^a-z]/g, '').slice(0, WORD_LENGTH)
61 await update($, draft, () => letters)
62}
63
64const REASONS = { length: 'Not enough letters', invalid: 'Not in word list' }
65
66const enter = async $ => {
67 const g = await read($, game)
68 if (!g || g.status !== 'playing') return
69 const word = await read($, draft)
70 if (word.length < WORD_LENGTH) return $.ui.toast(REASONS.length)
71
72 const isValid = await isValidGuess(makeIo($), word)
73 const out = submitGuess(g, word, () => isValid)
74 if (!out.ok) return $.ui.toast(REASONS[out.reason] ?? 'Cannot play that')
75 await update($, game, () => out.game)
76 await update($, draft, () => '')
77 if (out.game.status === 'won') $.ui.toast(`Solved in ${out.game.guesses.length}!`)
78 if (out.game.status === 'lost') $.ui.toast(`The word was ${out.game.answer.toUpperCase()}`)
79}
80
81/** @type {import('claude-code').Register} */
82export const register = on => {
83 on('session.start', async ($, e, next) => {
84 await $.command.register({
85 name: 'wordle',
86 description: 'Open the Wordle pane',
87 })
88
89 return next(e)
90 })
91
92 on('command.run', { command: 'wordle' }, async $ => {
93 await $.ui.open({ id: PANE, title: 'Wordle', focus: true, closeOnEscape: true })
94
95 return { text: 'Wordle pane opened.' }
96 })
97
98 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
99 if (e.requestId !== PANE) return next(e)
100 const { Box, Text, Button, Input } = $.ui.resolve(e)
101 const view = { game: await read($, game), draft: await read($, draft), puzzle: await read($, puzzle) }
102
103 if (!view.game && !isLoading) {
104 isLoading = true
105 $.clock.after(0, () => startToday($))
106 }
107
108 return renderBoard({ h, Box, Text, Button, Input }, view, {
109 letter: ch => typeLetter($, ch),
110 enter: () => enter($),
111 backspace: () => backspace($),
112 input: text => setDraft($, text),
113 fallbackInfo: () => $.ui.toast(FALLBACK_NOTE),
114 })
115 })
116}
117hooks/lib/board-view.js 126 lines1import { WORD_LENGTH } from './game-engine.js'
2
3export const FALLBACK_NOTE = "Couldn't reach the live word — showing an offline puzzle instead."
4
5const MAX_GUESSES = 6
6// Theme keys, so the colors follow the person's theme. Every scored letter is
7// also underlined, which keeps a gray (absent) letter distinct from an unplayed
8// one and gives color-blind players a second cue besides the legend.
9const SCORE_COLOR = { green: 'success', yellow: 'warning', gray: 'inactive' }
10const LEGEND = [
11 ['green', 'right letter, right place'],
12 ['yellow', 'right letter, wrong place'],
13 ['gray', 'letter not in the word'],
14]
15const KEY_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm']
16
17// A hotkey is one digit or one lowercase letter, so Enter and Backspace sit on
18// digits: all 26 letters belong to the letter keys.
19export const ENTER_KEY = '1'
20export const BACKSPACE_KEY = '2'
21
22/** Letters ruled out so far: gray everywhere they appear. */
23const ruledOut = game => {
24 const seen = new Set()
25 const kept = new Set()
26 for (const { word, score } of game.guesses) {
27 ;[...word].forEach((ch, i) => {
28 seen.add(ch)
29 if (score[i] !== 'gray') kept.add(ch)
30 })
31 }
32
33 return new Set([...seen].filter(ch => !kept.has(ch)))
34}
35
36/** One row of five letters: scored letters colored and underlined, typed ones plain. */
37const letterRow = (h, Box, Text, game, draft, r) => {
38 const played = game.guesses[r]
39 const isActive = r === game.guesses.length && game.status === 'playing'
40 const cells = Array.from({ length: WORD_LENGTH }, (_, i) => {
41 if (played) {
42 return h(Text, { key: i, bold: true, underline: true, color: SCORE_COLOR[played.score[i]] }, played.word[i].toUpperCase())
43 }
44 if (isActive && draft[i]) return h(Text, { key: i, bold: true }, draft[i].toUpperCase())
45
46 return h(Text, { key: i, dimColor: true }, '·')
47 })
48
49 return h(Box, { key: `row${r}`, flexDirection: 'row', gap: 1 }, ...cells)
50}
51
52const legend = (h, Box, Text) =>
53 h(
54 Box,
55 { flexDirection: 'column' },
56 h(Text, { dimColor: true }, 'Legend'),
57 ...LEGEND.map(([score, meaning]) =>
58 h(
59 Box,
60 { key: `legend-${score}`, flexDirection: 'row', gap: 1 },
61 h(Text, { bold: true, underline: true, color: SCORE_COLOR[score] }, 'A'),
62 h(Text, null, meaning),
63 ),
64 ),
65 )
66
67const statusLine = game =>
68 game.status === 'won'
69 ? `Solved in ${game.guesses.length}/${MAX_GUESSES} 🎉`
70 : game.status === 'lost'
71 ? `The word was ${game.answer.toUpperCase()}`
72 : `Guess ${game.guesses.length + 1}/${MAX_GUESSES} · Enter = ${ENTER_KEY}, ⌫ = ${BACKSPACE_KEY}`
73
74/**
75 * The pane's tree for one game state. Pure: the caller supplies the element
76 * factory `h`, the surface's `Box`/`Text`/`Button`, and the press callbacks, so
77 * this file never touches the engine's `$`.
78 *
79 * @param ui `{ h, Box, Text, Button, Input }`
80 * @param view `{ game, draft, puzzle }`; `game` null means still loading
81 * @param on `{ letter(ch), enter(), backspace(), input(text), fallbackInfo() }`
82 */
83export const renderBoard = ({ h, Box, Text, Button, Input }, { game, draft, puzzle }, on) => {
84 if (!game) return h(Box, { flexDirection: 'column' }, h(Text, null, 'Loading today’s puzzle…'))
85
86 const rows = Array.from({ length: MAX_GUESSES }, (_, r) => letterRow(h, Box, Text, game, draft, r))
87
88 const gray = ruledOut(game)
89 const keyboard = KEY_ROWS.map((row, i) => {
90 const keys = [...row].map(ch =>
91 h(Button, { key: `k-${ch}`, label: ch.toUpperCase(), hotkey: ch, dimColor: gray.has(ch), onPress: () => on.letter(ch) }),
92 )
93 if (i === 2) {
94 keys.unshift(h(Button, { key: 'enter', label: 'Enter', hotkey: ENTER_KEY, variant: 'primary', onPress: () => on.enter() }))
95 keys.push(h(Button, { key: 'back', label: '⌫', hotkey: BACKSPACE_KEY, onPress: () => on.backspace() }))
96 }
97
98 return h(Box, { key: `krow${i}`, flexDirection: 'row', gap: 1 }, ...keys)
99 })
100
101 return h(
102 Box,
103 { flexDirection: 'column', gap: 1 },
104 h(Box, { flexDirection: 'row', gap: 1 }, h(Text, { bold: true }, 'Wordle'), puzzle && h(Text, { dimColor: true }, puzzle.date)),
105 puzzle?.source === 'fallback' &&
106 h(Button, { key: 'fallback-warning', label: '⚠ offline puzzle', onPress: () => on.fallbackInfo() }),
107 h(Box, { flexDirection: 'column' }, ...rows),
108 h(Text, { dimColor: true }, statusLine(game)),
109 // The typing surface: the field edits `draft` natively (Backspace deletes the
110 // last letter, Enter submits); the on-screen keys feed the same draft.
111 game.status === 'playing' &&
112 h(Input, {
113 key: 'guess',
114 label: 'Guess: ',
115 placeholder: 'type a word',
116 value: draft,
117 autoFocus: true,
118 submitLabel: 'guess',
119 onInput: text => on.input(text),
120 onSubmit: () => on.enter(),
121 }),
122 // keyboard and legend side by side, the legend toward the right
123 h(Box, { flexDirection: 'row', gap: 4, alignItems: 'flex-end' }, h(Box, { flexDirection: 'column' }, ...keyboard), legend(h, Box, Text)),
124 )
125}
126hooks/lib/game-engine.js 60 lines1export const WORD_LENGTH = 5
2export const MAX_GUESSES = 6
3
4/**
5 * Scores `guess` against `answer`: an array of 'green' | 'yellow' | 'gray'.
6 * Greens claim their letters first; each remaining guess letter is yellow only
7 * while an unclaimed copy of it is left in the answer, so repeats never score
8 * more often than the answer holds them.
9 */
10export const scoreGuess = (guess, answer) => {
11 const g = guess.toLowerCase()
12 const a = answer.toLowerCase()
13 const result = Array(WORD_LENGTH).fill('gray')
14 const pool = {}
15
16 for (let i = 0; i < WORD_LENGTH; i++) {
17 if (g[i] === a[i]) result[i] = 'green'
18 else pool[a[i]] = (pool[a[i]] ?? 0) + 1
19 }
20 for (let i = 0; i < WORD_LENGTH; i++) {
21 if (result[i] === 'green' || !pool[g[i]]) continue
22 result[i] = 'yellow'
23 pool[g[i]]--
24 }
25
26 return result
27}
28
29/** A fresh game. Games are plain data; `submitGuess` returns new ones. */
30export const createGame = answer => ({
31 answer: answer.toLowerCase(),
32 guesses: [],
33 status: 'playing',
34})
35
36/**
37 * Plays `guess` into `game` without mutating it.
38 *
39 * `isValid` is a sync predicate for allowed guesses (the caller resolves the
40 * word list; this module does no I/O). Resolves to `{ ok: true, game }`, or
41 * `{ ok: false, reason }` with reason 'finished' | 'length' | 'invalid'; a
42 * rejected guess costs nothing.
43 */
44export const submitGuess = (game, guess, isValid) => {
45 if (game.status !== 'playing') return { ok: false, reason: 'finished' }
46 const word = String(guess).trim().toLowerCase()
47 if (!/^[a-z]+$/.test(word) || word.length !== WORD_LENGTH) return { ok: false, reason: 'length' }
48 if (!isValid(word)) return { ok: false, reason: 'invalid' }
49
50 const scored = { word, score: scoreGuess(word, game.answer) }
51 const guesses = [...game.guesses, scored]
52 const status = scored.score.every(s => s === 'green')
53 ? 'won'
54 : guesses.length >= MAX_GUESSES
55 ? 'lost'
56 : 'playing'
57
58 return { ok: true, game: { ...game, guesses, status } }
59}
60hooks/lib/word-source.js 97 lines1const LIVE_URL = date => `https://www.nytimes.com/svc/wordle/v2/${date}.json`
2const TIMEOUT_MS = 3000
3const DAY_MS = 86400000
4const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
5const WORD_RE = /^[a-z]{5}$/
6
7// Loaded once per module load; a hot reload simply re-reads the files.
8let fallbackList
9let guessSet
10
11/**
12 * `io` is what the library needs from the engine, built in register.js:
13 * `{ store: { get, set }, fetch, sleep, readData }`. The engine's `$` can't be
14 * passed across an import, so the caller wraps the calls it needs.
15 */
16const readLines = async (io, file) => {
17 const text = await io.readData(file)
18
19 return text
20 .split('\n')
21 .map(line => line.trim().toLowerCase())
22 .filter(Boolean)
23}
24
25const loadFallback = async io => {
26 fallbackList ??= readLines(io, 'fallback-answers.txt').catch(error => {
27 fallbackList = undefined
28 throw error
29 })
30
31 return fallbackList
32}
33
34const loadGuesses = async io => {
35 guessSet ??= readLines(io, 'valid-guesses.txt')
36 .then(words => new Set(words))
37 .catch(error => {
38 guessSet = undefined
39 throw error
40 })
41
42 return guessSet
43}
44
45/** Whole days between 1970-01-01 and `dateStr` (YYYY-MM-DD), in UTC. */
46export const daysSinceEpoch = dateStr => Math.floor(Date.parse(`${dateStr}T00:00:00Z`) / DAY_MS)
47
48const fetchLive = async (io, dateStr) => {
49 const response = await Promise.race([
50 io.fetch(LIVE_URL(dateStr)),
51 io.sleep(TIMEOUT_MS).then(() => {
52 throw new Error('timeout')
53 }),
54 ])
55 if (!response.ok) throw new Error(`status ${response.status}`)
56 const solution = JSON.parse(response.text)?.solution
57 if (typeof solution !== 'string' || !WORD_RE.test(solution.toLowerCase())) {
58 throw new Error('missing solution')
59 }
60
61 return solution.toLowerCase()
62}
63
64/**
65 * The answer for `dateStr` (YYYY-MM-DD): cached, else live from NYT, else a
66 * deterministic offline word. Never throws for a network or schema problem.
67 *
68 * @returns {Promise<{ solution: string, source: 'live' | 'fallback' }>}
69 */
70export const resolveWord = async (io, dateStr) => {
71 if (!DATE_RE.test(dateStr) || Number.isNaN(Date.parse(dateStr))) {
72 throw new Error(`resolveWord: expected YYYY-MM-DD, got ${dateStr}`)
73 }
74 const key = `word:${dateStr}`
75 const cached = await io.store.get(key)
76 if (cached) return cached
77
78 let result
79 try {
80 result = { solution: await fetchLive(io, dateStr), source: 'live' }
81 } catch {
82 const list = await loadFallback(io)
83 const index = ((daysSinceEpoch(dateStr) % list.length) + list.length) % list.length
84 result = { solution: list[index], source: 'fallback' }
85 }
86 await io.store.set(key, result)
87
88 return result
89}
90
91/** Whether `word` is an allowed guess (answers are included in the list). */
92export const isValidGuess = async (io, word) => {
93 const guesses = await loadGuesses(io)
94
95 return guesses.has(String(word).trim().toLowerCase())
96}
97types/index.d.ts 26 lines1export type WordleScore = 'green' | 'yellow' | 'gray'
2
3export type WordleGame = {
4 answer: string
5 guesses: { word: string; score: WordleScore[] }[]
6 status: 'playing' | 'won' | 'lost'
7}
8
9/** The day's puzzle: its date and where the word came from. */
10export type WordlePuzzle = {
11 date: string
12 source: 'live' | 'fallback'
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'wordle-mod': {
18 /** The game in play; null until the day's word has loaded. */
19 game: WordleGame | null
20 /** Letters typed toward the next guess (0-5, lowercase). */
21 draft: string
22 puzzle: WordlePuzzle | null
23 }
24 }
25}
26