SLOPSHOPPER

wordle-mod

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

newpanecommandtoastnetworktimer
v0.1.0no licenseupdated 2026-10-09Sumo-99/claude_wordle_mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wordle-mod
│ ┃ Wordle ✕ › fix the failing auth test and add an audit log call │ ┃ Loading today’s puzzle… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wordle │ ⎿ wordle-mod: Wordle pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Wordle
Loading today’s puzzle…
Source 5 files
hooks/register.js 117 lines
1import { 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}
117
hooks/lib/board-view.js 126 lines
1import { 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}
126
hooks/lib/game-engine.js 60 lines
1export 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}
60
hooks/lib/word-source.js 97 lines
1const 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}
97
types/index.d.ts 26 lines
1export 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