A daily five-letter coding word puzzle inside Claude Code, with streaks and a leaderboard

A daily five-letter coding word puzzle you play inside Claude Code. Everyone gets the same word each day and has six guesses. Streaks and a leaderboard keep score. It draws in the terminal and in the Code tab of Claude Desktop.
Run /cg-daily-diff and click the pane to start. Type a word and press Enter. Green means the right letter in the right place, yellow means the letter is in the word elsewhere, gray means it isn't there. A new puzzle starts at 00:00 UTC.
Daily Diff needs a one-time GitHub sign-in, because the server keeps your guesses and streak and puts you on the leaderboard. The game shows a code, you enter it at github.com/login/device, and that's it. The sign-in asks for no permissions, so the server only learns your public username. The game stores a session key, never your GitHub token.
The game contacts two hosts, both over HTTPS: github.com for sign-in, and the official game server at https://games.jpoapps.com for everything else.
The game stores one thing on your machine through Claude Code: the session key for the game server. It sends that key only to the game server, never anywhere else, and it never stores your GitHub token.
The server stores your GitHub username, each day's guesses, when you started and finished, and whether you solved it. No word list or answers ship in this plugin. Full details are in the privacy policy.
The game adds one command, /cg-daily-diff, and runs no other commands, tools or programs. Its hooks only handle that command (opening the pane, or signing out with signout), draw the pane, take keys typed into the pane, and forget the in-progress state when the pane closes. It never reads, stores or sends anything you or Claude write in the session. Share copies a grid of squares to your clipboard only when you press s.
tests/ holds the game's automated tests, run with claude plugin test. They never load in a player's session. They stand in for Claude Code's own events (network replies, stored data and commands) so the game can be tested without a network or a real session; the game itself makes no calls beyond the ones listed above.
MIT
hooks/register.tsx 300 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpInit, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { ClientMsg, Period, View } from '../types'
5import { parseConfig, parseSession, pollToken, requestDeviceCode, sessionKey } from './auth'
6import type { Session } from './auth'
7import { call, parseBoard, parseClientMsg, parseStats, parseToday } from './net'
8import type { Reply } from './net'
9import { shareText } from './text'
10
11const PANE = 'daily-diff'
12const OFFICIAL = 'https://games.jpoapps.com'
13const KEY = sessionKey(OFFICIAL)
14const TIMEOUT_MS = 30_000
15
16const DOWN = 'Game server unreachable.'
17const OUTDATED = 'Daily Diff is out of date. Run claude plugin update daily-diff@claude-games in your shell, then /reload-plugins.'
18const UNAVAILABLE = 'Daily Diff is not available right now.'
19const NOT_A_WORD = 'Not in word list'
20const NEW_PUZZLE = 'A new puzzle is out.'
21const SIGNING_IN = 'Signing in with GitHub...'
22const NO_SIGNIN = "This server isn't set up for sign-in."
23const NO_GITHUB = "Couldn't reach GitHub to sign in."
24const CANCELLED = 'Sign-in was cancelled or expired.'
25
26const startView = (): View => ({ me: null, notice: null, today: null, board: null, stats: null, isSending: false, rejected: 0, copied: null, now: 0 })
27const view = atom({ plugin: 'daily-diff', key: 'view' } as const, startView())
28
29type Failure = { status: number; error: string }
30
31const rt: {
32 session: Session | null
33 signIn: Timer | null
34 isSigningIn: boolean
35 // Bumped when the pane closes so a sign-in still waiting on a request stops when it resumes.
36 signInGen: number
37 // The code line on screen while sign-in runs, shown again if the pane is reopened meanwhile.
38 signInLine: string | null
39 // Bumped on every refresh and close. An async flow keeps the value it started with and writes nothing once it has moved on.
40 gen: number
41 // The generation whose guess is in flight, so a stale one never blocks the next.
42 sending: number | null
43 period: Period | null
44} = { session: null, signIn: null, isSigningIn: false, signInGen: 0, signInLine: null, gen: 0, sending: null, period: null }
45
46const isLive = (gen: number) => gen === rt.gen
47const setView = ($: EngineInterface, patch: Partial<View>) => update($, view, v => ({ ...v, ...patch }))
48// The generation is checked again inside the update: the SDK can hold a write after the caller's own check.
49const setViewIf = ($: EngineInterface, gen: number, patch: Partial<View>) => update($, view, v => (isLive(gen) ? { ...v, ...patch } : v))
50
51const fetch = ($: EngineInterface) => (url: string, init?: HttpInit) => $.http.fetch(url, init)
52const giveUp = ($: EngineInterface) => (signal: AbortSignal) => $.clock.sleep(TIMEOUT_MS, { signal })
53
54function noticeFor(r: Failure): string {
55 if (r.status === 0) return DOWN
56 if (r.status === 426) return OUTDATED
57 if (r.status === 503) return UNAVAILABLE
58
59 return `Server said: ${r.error}`
60}
61
62async function loadSession($: EngineInterface): Promise<Session | null> {
63 if (rt.session === null) rt.session = parseSession(await $.store.get(KEY))
64
65 return rt.session
66}
67
68function stopSignIn() {
69 rt.signIn?.cancel()
70 rt.signIn = null
71 rt.isSigningIn = false
72 rt.signInLine = null
73 rt.signInGen++
74}
75
76// Forgets only the session that was sent: a newer one saved meanwhile stays.
77async function dropSession($: EngineInterface, sent: string) {
78 if (rt.session?.session === sent) rt.session = null
79 if (parseSession(await $.store.get(KEY))?.session === sent) await $.store.delete(KEY)
80 await update($, view, v => (rt.session === null ? { ...v, me: null } : v))
81}
82
83async function signOut($: EngineInterface): Promise<string> {
84 const s = await loadSession($)
85 if (s === null) return 'Not signed in.'
86 await call(fetch($), OFFICIAL, 'DELETE', '/v1/session', s.session, undefined, giveUp($))
87 await dropSession($, s.session)
88
89 return 'Signed out of Daily Diff.'
90}
91
92async function startSignIn($: EngineInterface) {
93 if (rt.isSigningIn) {
94 const line = rt.signInLine
95 if (line) await update($, view, v => (rt.signInLine === line ? { ...v, notice: line } : v))
96
97 return
98 }
99 rt.isSigningIn = true
100 const gen = ++rt.signInGen
101 const live = () => gen === rt.signInGen
102 const say = (notice: string) => update($, view, v => (live() ? { ...v, notice } : v))
103 const end = (notice: string) => {
104 if (!live()) return
105 rt.isSigningIn = false
106 rt.signIn = null
107 rt.signInLine = null
108
109 return say(notice)
110 }
111 const f = fetch($)
112 const g = giveUp($)
113 try {
114 const config = await call(f, OFFICIAL, 'GET', '/v1/config', null, undefined, g)
115 if (!live()) return
116 if (!config.ok) return end(noticeFor(config))
117 const clientId = parseConfig(config.data)
118 if (!clientId) return end(NO_SIGNIN)
119 const code = await requestDeviceCode(f, clientId, g)
120 if (!live()) return
121 if (!code) return end(NO_GITHUB)
122 rt.signInLine = `Sign in: open ${code.uri} and enter ${code.userCode}`
123 await say(rt.signInLine)
124 const startedAt = await $.clock.now()
125 let interval = code.interval
126 const tick = async () => {
127 try {
128 if (!live()) return
129 if ((await $.clock.now()) - startedAt > code.expiresIn * 1000) return end(CANCELLED)
130 const poll = await pollToken(f, clientId, code.deviceCode, g)
131 if (!live()) return
132 if (poll.kind === 'pending' || poll.kind === 'slowDown') {
133 if (poll.kind === 'slowDown') interval += 5
134 rt.signIn = $.clock.after(interval * 1000, () => void tick())
135
136 return
137 }
138 if (poll.kind === 'failed') return end(poll.reason === 'error' ? NO_GITHUB : CANCELLED)
139 await say(SIGNING_IN)
140 if (!live()) return
141 const r = await call(f, OFFICIAL, 'POST', '/v1/session', null, { githubToken: poll.token }, g)
142 if (!live()) return
143 const s = r.ok ? parseSession(r.data) : null
144 if (!s) return end(r.ok ? DOWN : noticeFor(r))
145 rt.session = s
146 await $.store.set(KEY, s)
147 if (!live()) return
148 rt.isSigningIn = false
149 rt.signIn = null
150 rt.signInLine = null
151 void refresh($, `Signed in as ${s.login}.`)
152 } catch {
153 await end(NO_GITHUB)
154 }
155 }
156 rt.signIn = $.clock.after(interval * 1000, () => void tick())
157 } catch {
158 await end(NO_GITHUB)
159 }
160}
161
162// No session answers 401, which every caller turns into a sign-in.
163async function send($: EngineInterface, method: 'GET' | 'POST', path: string, body?: unknown): Promise<Reply<unknown>> {
164 const s = await loadSession($)
165 if (s === null) return { ok: false, status: 401, error: '' }
166 const r = await call(fetch($), OFFICIAL, method, path, s.session, body, giveUp($))
167 if (!r.ok && r.status === 401) await dropSession($, s.session)
168
169 return r
170}
171
172async function fail($: EngineInterface, gen: number, r: Failure) {
173 if (!isLive(gen)) return
174 if (r.status === 401) return startSignIn($)
175 await setViewIf($, gen, { notice: noticeFor(r) })
176}
177
178const BAD_REPLY: Failure = { status: 0, error: '' }
179
180async function loadFinished($: EngineInterface, gen: number) {
181 rt.period = 'today'
182 const [st, b] = await Promise.all([send($, 'GET', '/v1/daily-diff/stats'), send($, 'GET', '/v1/daily-diff/leaderboard/today')])
183 const stats = st.ok ? parseStats(st.data) : null
184 const board = b.ok ? parseBoard(b.data) : null
185 await update($, view, v =>
186 isLive(gen) ? { ...v, stats: stats ?? v.stats, board: board && rt.period === 'today' ? board : v.board } : v,
187 )
188 if (!stats) return fail($, gen, st.ok ? BAD_REPLY : st)
189 if (!board) return fail($, gen, b.ok ? BAD_REPLY : b)
190}
191
192async function refresh($: EngineInterface, notice: string | null = null) {
193 const gen = ++rt.gen
194 const r = await send($, 'GET', '/v1/daily-diff/today')
195 const today = r.ok ? parseToday(r.data) : null
196 if (!today) return fail($, gen, r.ok ? BAD_REPLY : r)
197 await setViewIf($, gen, { me: rt.session?.login ?? null, today, notice, isSending: false, now: await $.clock.now() })
198 if (today.state !== 'playing') await loadFinished($, gen)
199}
200
201async function guess($: EngineInterface, word: string) {
202 const gen = rt.gen
203 if (rt.sending === gen) return
204 const { today } = await read($, view)
205 if (!isLive(gen) || rt.sending === gen || today?.state !== 'playing') return
206 rt.sending = gen
207 let r: Reply<unknown>
208 try {
209 await setViewIf($, gen, { isSending: true })
210 r = await send($, 'POST', '/v1/daily-diff/guess', { number: today.number, word })
211 } finally {
212 if (rt.sending === gen) rt.sending = null
213 await setViewIf($, gen, { isSending: false })
214 }
215 if (!isLive(gen)) return
216 if (r.ok) {
217 const next = parseToday(r.data)
218 if (!next) return fail($, gen, BAD_REPLY)
219 await setViewIf($, gen, { today: next, notice: null, now: await $.clock.now() })
220 if (next.state !== 'playing') await loadFinished($, gen)
221
222 return
223 }
224 if (r.status === 422) return void (await update($, view, v => (isLive(gen) ? { ...v, rejected: v.rejected + 1, notice: NOT_A_WORD } : v)))
225 if (r.status === 409) return refresh($, r.error === 'new puzzle' ? NEW_PUZZLE : null)
226
227 return fail($, gen, r)
228}
229
230async function showBoard($: EngineInterface, period: Period) {
231 const gen = rt.gen
232 rt.period = period
233 const r = await send($, 'GET', `/v1/daily-diff/leaderboard/${period}`)
234 const b = r.ok ? parseBoard(r.data) : null
235 if (!b) return fail($, gen, r.ok ? BAD_REPLY : r)
236 // Only the tab asked for last is shown, whatever order the replies land in.
237 await update($, view, v => (isLive(gen) && rt.period === period ? { ...v, board: b } : v))
238}
239
240async function share($: EngineInterface, surface: RenderSurface) {
241 const gen = rt.gen
242 const { today } = await read($, view)
243 if (!today || today.state === 'playing') return
244 const r = await $.ui.copy({ text: shareText(today), surface })
245 await setViewIf($, gen, { copied: r.isCopied ? 'ok' : 'failed' })
246}
247
248function handle($: EngineInterface, m: ClientMsg, surface: RenderSurface) {
249 if (m.type === 'guess') return guess($, m.word)
250 if (m.type === 'board') return showBoard($, m.period)
251 if (m.type === 'share') return share($, surface)
252
253 return refresh($)
254}
255
256export const register: Register = on => {
257 on('session.start', async ($, e, next) => {
258 await $.command.register({ name: 'cg-daily-diff', description: `Today's coding word puzzle. Add "signout" to sign out` })
259
260 return next(e)
261 })
262
263 on('command.run', { command: 'cg-daily-diff' }, async ($, e) => {
264 if (e.args.trim() === 'signout') return { text: await signOut($) }
265 await setView($, { notice: null, copied: null, isSending: false })
266 const opened = await $.ui.open({ id: PANE, title: 'Daily Diff', focus: true, closeOnEscape: true, holdToasts: true, columns: 60, rows: 40 })
267 if (!opened.isPlaced) return { text: `Daily Diff could not be shown here: ${opened.reason}` }
268 void refresh($)
269
270 // The engine refuses $.ui.focus on a Client; only the person's click gives it the keys.
271 return { text: 'Daily Diff open. Click the pane to type; Esc closes it.' }
272 })
273
274 on('ui.close', async ($, e, next) => {
275 if (e.id === PANE) {
276 stopSignIn()
277 rt.gen++
278 }
279
280 return next(e)
281 })
282
283 on('ui.message', async ($, e, next) => {
284 if (e.element !== 'game') return next(e)
285 const m = parseClientMsg(e.data)
286 if (m) void handle($, m, e.surface)
287
288 return next(e)
289 })
290
291 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
292 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
293 const ui = $.ui.resolve(e)
294 const v = await read($, view)
295
296 // Without a size the region is as tall as what the module draws.
297 return <ui.Client key="game" module="./game.tsx" props={v} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
298 })
299}
300hooks/auth.ts 96 lines1import { login, withGiveUp } from './net'
2import type { Fetch, GiveUp } from './net'
3
4const DEVICE_URL = 'https://github.com/login/device/code'
5const TOKEN_URL = 'https://github.com/login/oauth/access_token'
6const FORM = { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }
7
8export type DeviceCode = { deviceCode: string; userCode: string; uri: string; interval: number; expiresIn: number }
9export type TokenPoll =
10 | { kind: 'token'; token: string }
11 | { kind: 'pending' }
12 | { kind: 'slowDown' }
13 | { kind: 'failed'; reason: 'expired' | 'denied' | 'error' }
14export type Session = { session: string; login: string }
15
16const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
17const str = (v: unknown) => (typeof v === 'string' && v !== '' ? v : null)
18
19const CLIENT_ID = /^[A-Za-z0-9._-]{1,64}$/
20const SESSION = /^[\x21-\x7e]{1,200}$/
21const never: GiveUp = () => new Promise(() => undefined)
22
23export function parseConfig(data: unknown): string | null {
24 if (!isRecord(data)) return null
25 const id = data.githubClientId
26
27 return typeof id === 'string' && CLIENT_ID.test(id) ? id : null
28}
29
30export const sessionKey = (base: string) => 'session:' + base.replace(/\/+$/, '')
31
32export function parseDeviceCode(data: unknown): DeviceCode | null {
33 if (!isRecord(data)) return null
34 const deviceCode = str(data.device_code)
35 const userCode = str(data.user_code)
36 const uri = str(data.verification_uri)
37 if (!deviceCode || !userCode || !uri) return null
38
39 return {
40 deviceCode,
41 userCode,
42 uri,
43 interval: typeof data.interval === 'number' ? data.interval : 5,
44 expiresIn: typeof data.expires_in === 'number' ? data.expires_in : 900,
45 }
46}
47
48export function parseTokenPoll(data: unknown): TokenPoll {
49 if (!isRecord(data)) return { kind: 'failed', reason: 'error' }
50 const token = str(data.access_token)
51 if (token) return { kind: 'token', token }
52 if (data.error === 'authorization_pending') return { kind: 'pending' }
53 if (data.error === 'slow_down') return { kind: 'slowDown' }
54 if (data.error === 'expired_token') return { kind: 'failed', reason: 'expired' }
55 if (data.error === 'access_denied') return { kind: 'failed', reason: 'denied' }
56
57 return { kind: 'failed', reason: 'error' }
58}
59
60export function parseSession(data: unknown): Session | null {
61 if (!isRecord(data)) return null
62 const session = typeof data.session === 'string' && SESSION.test(data.session) ? data.session : null
63 const name = login(data.login)
64
65 return session && name ? { session, login: name } : null
66}
67
68export async function requestDeviceCode(fetch: Fetch, clientId: string, giveUp: GiveUp = never): Promise<DeviceCode | null> {
69 fetch = withGiveUp(fetch, giveUp)
70 try {
71 const body = new URLSearchParams({ client_id: clientId, scope: '' }).toString()
72 const res = await fetch(DEVICE_URL, { method: 'POST', headers: FORM, body })
73
74 return parseDeviceCode(JSON.parse(res.text))
75 } catch {
76 return null
77 }
78}
79
80// A dropped or timed-out request counts as pending so the next tick asks again.
81export async function pollToken(fetch: Fetch, clientId: string, deviceCode: string, giveUp: GiveUp = never): Promise<TokenPoll> {
82 fetch = withGiveUp(fetch, giveUp)
83 try {
84 const body = new URLSearchParams({
85 client_id: clientId,
86 device_code: deviceCode,
87 grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
88 }).toString()
89 const res = await fetch(TOKEN_URL, { method: 'POST', headers: FORM, body })
90
91 return parseTokenPoll(JSON.parse(res.text))
92 } catch {
93 return { kind: 'pending' }
94 }
95}
96hooks/net.ts 133 lines1import type { HttpInit, HttpResponse } from 'claude-code'
2
3import type { Board, BoardRow, ClientMsg, Period, Stats, Today } from '../types'
4
5export type Fetch = (url: string, init?: HttpInit) => Promise<HttpResponse>
6
7export type GiveUp = (signal: AbortSignal) => Promise<void>
8
9// The returned fetch rejects once giveUp resolves, and cancels giveUp once the request settles.
10export const withGiveUp =
11 (fetch: Fetch, giveUp: GiveUp): Fetch =>
12 async (url, init) => {
13 const stop = new AbortController()
14 const timedOut = giveUp(stop.signal).then(
15 () => Promise.reject(new Error('timed out')),
16 () => new Promise<never>(() => undefined),
17 )
18 try {
19 return await Promise.race([fetch(url, init), timedOut])
20 } finally {
21 stop.abort()
22 }
23 }
24
25export type Reply<T> = { ok: true; data: T } | { ok: false; status: number; error: string }
26
27export const PROTOCOL_VERSION = 1
28export const GAME = 'daily-diff'
29
30export function buildRequest(
31 base: string,
32 method: 'GET' | 'POST' | 'DELETE',
33 path: string,
34 token: string | null,
35 body?: unknown,
36): { url: string; init: HttpInit } {
37 const headers: Record<string, string> = {
38 ...(token === null ? {} : { Authorization: `Bearer ${token}` }),
39 Accept: 'application/json',
40 'X-Protocol-Version': String(PROTOCOL_VERSION),
41 'X-Game': GAME,
42 }
43 if (body !== undefined) headers['Content-Type'] = 'application/json'
44
45 return {
46 url: base.replace(/\/+$/, '') + path,
47 init: { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
48 }
49}
50
51export async function call(
52 fetch: Fetch,
53 base: string,
54 method: 'GET' | 'POST' | 'DELETE',
55 path: string,
56 token: string | null,
57 body?: unknown,
58 giveUp: GiveUp = () => new Promise(() => undefined),
59): Promise<Reply<unknown>> {
60 const { url, init } = buildRequest(base, method, path, token, body)
61 try {
62 const res = await withGiveUp(fetch, giveUp)(url, init)
63 let data: unknown = null
64 try {
65 data = JSON.parse(res.text)
66 } catch {
67 // a non-JSON body (a proxy error page) is handled below by status
68 }
69 if (res.ok) return { ok: true, data }
70 const error = (data as { error?: unknown } | null)?.error
71
72 return { ok: false, status: res.status, error: typeof error === 'string' ? cleanText(error) : `HTTP ${res.status}` }
73 } catch {
74 return { ok: false, status: 0, error: 'unreachable' }
75 }
76}
77
78const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
79const LOGIN = /^[A-Za-z0-9-]{1,39}$/
80export const login = (v: unknown): string | null => (typeof v === 'string' && LOGIN.test(v) ? v : null)
81export const cleanText = (s: string) => s.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, '').slice(0, 120)
82
83const WORD = /^[a-z]{5}$/
84const MARKS = /^[gyx]{5}$/
85const DAY = /^\d{4}-\d{2}-\d{2}$/
86const PERIODS: readonly Period[] = ['today', 'week', 'month', 'all']
87const count = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
88const isPeriod = (v: unknown): v is Period => PERIODS.includes(v as Period)
89
90export function parseToday(d: unknown): Today | null {
91 if (!isRecord(d) || !count(d.number) || typeof d.day !== 'string' || !DAY.test(d.day) || !count(d.endsAt)) return null
92 if (d.state !== 'playing' && d.state !== 'won' && d.state !== 'lost') return null
93 if (!Array.isArray(d.guesses) || d.guesses.length > 6) return null
94 const guesses = d.guesses.filter(isRecord).filter(g => typeof g.word === 'string' && WORD.test(g.word) && typeof g.marks === 'string' && MARKS.test(g.marks))
95 if (guesses.length !== d.guesses.length) return null
96 const answer = d.state !== 'playing' && typeof d.answer === 'string' && WORD.test(d.answer) ? d.answer : null
97
98 return { number: d.number, day: d.day, endsAt: d.endsAt, guesses: guesses.map(g => ({ word: g.word as string, marks: g.marks as string })), state: d.state, answer }
99}
100
101function boardRow(r: unknown): BoardRow | null {
102 if (!isRecord(r) || !count(r.rank) || !login(r.login) || !count(r.played) || !count(r.ms)) return null
103 const guesses = count(r.guesses) && r.guesses >= 1 && r.guesses <= 6 ? r.guesses : null
104
105 return { rank: r.rank, login: r.login as string, points: count(r.points) ? r.points : null, guesses, played: r.played, ms: r.ms }
106}
107
108export function parseBoard(d: unknown): Board | null {
109 if (!isRecord(d) || !isPeriod(d.period) || !Array.isArray(d.rows)) return null
110
111 return { period: d.period, rows: d.rows.map(boardRow).filter((r): r is BoardRow => r !== null).slice(0, 20), you: boardRow(d.you) }
112}
113
114export function parseStats(d: unknown): Stats | null {
115 if (!isRecord(d) || !count(d.played) || !count(d.won) || !count(d.streak) || !count(d.bestStreak)) return null
116 if (!Array.isArray(d.distribution) || d.distribution.length !== 6 || !d.distribution.every(count)) return null
117
118 return { played: d.played, won: d.won, streak: d.streak, bestStreak: d.bestStreak, distribution: d.distribution as number[] }
119}
120
121// A Client posts plain data the hooks side must treat as input, not fact.
122export function parseClientMsg(d: unknown): ClientMsg | null {
123 if (!isRecord(d)) return null
124 if (d.type === 'guess' && typeof d.word === 'string') {
125 const word = d.word.trim().toLowerCase()
126 return WORD.test(word) ? { type: 'guess', word } : null
127 }
128 if (d.type === 'board') return isPeriod(d.period) ? { type: 'board', period: d.period } : null
129 if (d.type === 'share' || d.type === 'retry') return { type: d.type }
130
131 return null
132}
133hooks/text.ts 34 lines1import type { GuessRow, Today } from '../types'
2
3type M = 'g' | 'y' | 'x'
4const SQUARE: Record<M, string> = { g: '■', y: '▒', x: '□' }
5const RANK: Record<M, number> = { x: 0, y: 1, g: 2 }
6
7export function shareText(t: Today): string {
8 const score = t.state === 'won' ? `${t.guesses.length}/6` : 'X/6'
9 const rows = t.guesses.map(g => [...g.marks].map(m => SQUARE[m as M]).join(''))
10
11 return [`Daily Diff #${t.number} ${score}`, '', ...rows, '', '/plugin install daily-diff@claude-games'].join('\n')
12}
13
14export function keyMarks(guesses: GuessRow[]): Record<string, M> {
15 const out: Record<string, M> = {}
16 for (const g of guesses)
17 [...g.word].forEach((ch, i) => {
18 const m = g.marks[i] as M
19 if (out[ch] === undefined || RANK[m] > RANK[out[ch]]) out[ch] = m
20 })
21
22 return out
23}
24
25const two = (n: number) => String(n).padStart(2, '0')
26
27export function countdown(ms: number): string {
28 const s = Math.max(0, Math.floor(ms / 1000))
29 const h = Math.floor(s / 3600)
30 const m = Math.floor((s % 3600) / 60)
31
32 return h > 0 ? `${h}h ${two(m)}m` : `${m}m ${two(s % 60)}s`
33}
34hooks/game.tsx 402 lines1import type { ClientModule, ClientSurface, RenderElement } from 'claude-code'
2
3import type { Board, ClientMsg, Period, Stats, Today, View } from '../types'
4import { countdown, keyMarks } from './text'
5
6const GREEN = '#538d4e'
7const YELLOW = '#b59f3b'
8const GRAY = '#6a6a6c'
9const MARK: Record<string, string> = { g: GREEN, y: YELLOW, x: GRAY }
10const EDGE = '#565758'
11const EMPTY = '#3a3a3c'
12
13const TICK_MS = 100
14const FLASH_MS = 600
15const PERIODS: Period[] = ['today', 'week', 'month', 'all']
16const TAB: Record<Period, string> = { today: 'Today', week: 'Week', month: 'Month', all: 'All' }
17const KEYS = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM']
18const BAR_W = 20
19const LOGIN_W = 20
20
21const WIDE = 57
22const MIN_COLUMNS = 44
23// Rows each layout below takes, gaps included. A notice adds two more.
24const PLAY_ROWS = 27
25const SMALL_PLAY_ROWS = 14
26const DONE_ROWS = 18
27// The finished screen with full tiles: header, gap and six rows of 3-row tiles, with the results beside them.
28const TILES_ROWS = 20
29const RESULTS_W = 51
30const TILES_W = 25 + 2 + RESULTS_W
31
32type Live = {
33 props: View
34 rev: number
35 t: number
36 typed: string
37 flashUntil: number
38 tab: number
39 seenRejected: number
40 // Puzzle number and guess count, so a new guess or a new puzzle clears the typing.
41 seenGuesses: string
42 seenPeriod: Period | null
43 // props.now and the frame time it arrived at: the engine's time now is props.now plus the frames since.
44 seenNow: number
45 nowAt: number
46 // Puzzle number whose countdown already asked for the next one.
47 askedNext: number
48}
49type Shell = { live: Live; rev: number }
50type Surf = ClientSurface<Shell>
51
52const isDone = (t: Today | null) => t !== null && t.state !== 'playing'
53
54function onKey(live: Live, key: string, post: (m: ClientMsg) => void) {
55 const { today, isSending } = live.props
56 const k = key.length === 1 ? key.toLowerCase() : key
57 if (today === null) {
58 if (k === 'r') post({ type: 'retry' })
59
60 return
61 }
62 if (isDone(today)) {
63 if (k === 'left' || k === 'right') {
64 live.tab = (live.tab + (k === 'left' ? PERIODS.length - 1 : 1)) % PERIODS.length
65 post({ type: 'board', period: PERIODS[live.tab]! })
66 } else if (k === 's') post({ type: 'share' })
67 else if (k === 'r') post({ type: 'retry' })
68
69 return
70 }
71 if (isSending) return
72 if (/^[a-z]$/.test(k) && live.typed.length < 5) live.typed += k
73 else if (k === 'backspace' || k === 'delete') live.typed = live.typed.slice(0, -1)
74 else if (k === 'return' && live.typed.length === 5) post({ type: 'guess', word: live.typed })
75}
76
77const guessKey = (t: Today | null) => `${t?.number}:${t?.guesses.length}`
78const timeLeft = (live: Live, t: Today) => t.endsAt - (live.props.now + live.t - live.nowAt)
79
80// Picks up what changed in the props since the last call: a new guess clears the typing, a rejection
81// flashes, and the tab follows the board the hooks show (they only ever show the one asked for last).
82function sync(live: Live, props: View) {
83 live.props = props
84 const g = guessKey(props.today)
85 if (g !== live.seenGuesses) {
86 live.seenGuesses = g
87 live.typed = ''
88 }
89 const period = props.board?.period ?? null
90 if (period !== live.seenPeriod) {
91 live.seenPeriod = period
92 if (period) live.tab = PERIODS.indexOf(period)
93 }
94 if (props.now !== live.seenNow) {
95 live.seenNow = props.now
96 live.nowAt = live.t
97 }
98 if (props.rejected !== live.seenRejected) {
99 live.seenRejected = props.rejected
100 live.flashUntil = live.t + FLASH_MS
101 }
102}
103
104function tile(s: Surf, letter: string, opts: { bg?: string; border: string; dim?: boolean }) {
105 const { Box, Text } = s.elements
106
107 return (
108 <Box width={5} height={3} borderStyle="round" borderColor={opts.border} backgroundColor={opts.bg} justifyContent="center" alignItems="center">
109 <Text bold color={opts.bg ? 'white' : undefined} dimColor={opts.dim}>
110 {letter.toUpperCase() || ' '}
111 </Text>
112 </Box>
113 )
114}
115
116function grid(live: Live, s: Surf, today: Today, gap = 1) {
117 const { Box } = s.elements
118 const flash = live.t < live.flashUntil
119 const rows: RenderElement[] = []
120 for (let i = 0; i < 6; i++) {
121 const g = today.guesses[i]
122 const isTyping = i === today.guesses.length
123 const cells = [0, 1, 2, 3, 4].map(j => {
124 if (g) return tile(s, g.word[j]!, { bg: MARK[g.marks[j]!], border: MARK[g.marks[j]!]! })
125 if (!isTyping) return tile(s, '', { border: EMPTY })
126 const ch = live.typed[j] ?? ''
127
128 return tile(s, ch, { border: flash ? 'red' : ch ? EDGE : EMPTY, dim: live.props.isSending })
129 })
130 rows.push(
131 <Box key={`row-${i}`} flexDirection="row" gap={gap}>
132 {cells}
133 </Box>,
134 )
135 }
136
137 return <Box flexDirection="column">{rows}</Box>
138}
139
140// One row per guess, for the finished screen and for short panes. Cells touch so a guess reads as a word.
141function smallGrid(live: Live, s: Surf, today: Today, rowGap = 0) {
142 const { Box, Text } = s.elements
143 const flash = live.t < live.flashUntil
144 const isPlaying = today.state === 'playing'
145
146 return (
147 <Box flexDirection="column" rowGap={rowGap}>
148 {[0, 1, 2, 3, 4, 5].map(i => {
149 const g = today.guesses[i]
150 const isTyping = isPlaying && i === today.guesses.length
151
152 return (
153 <Box key={`row-${i}`} flexDirection="row">
154 {[0, 1, 2, 3, 4].map(j => {
155 if (g) return <Text bold color="white" backgroundColor={MARK[g.marks[j]!]}>{` ${g.word[j]!.toUpperCase()} `}</Text>
156 const ch = isTyping ? (live.typed[j] ?? '') : ''
157 if (isTyping && (ch || flash))
158 return (
159 <Text bold color={flash ? 'red' : undefined} dimColor={live.props.isSending} underline>
160 {` ${ch.toUpperCase() || '·'} `}
161 </Text>
162 )
163
164 return <Text dimColor>{' · '}</Text>
165 })}
166 </Box>
167 )
168 })}
169 </Box>
170 )
171}
172
173function keyboard(s: Surf, today: Today) {
174 const { Box, Text } = s.elements
175 const marks = keyMarks(today.guesses)
176 const key = (label: string, m?: string) => (
177 <Text bold color={m ? 'white' : undefined} backgroundColor={m ? MARK[m] : undefined}>{` ${label} `}</Text>
178 )
179
180 return (
181 <Box flexDirection="column" alignItems="center" rowGap={1}>
182 {KEYS.map((letters, i) => (
183 <Box flexDirection="row" gap={1}>
184 {i === 2 ? key('ENTER') : null}
185 {[...letters].map(c => key(c, marks[c.toLowerCase()]))}
186 {i === 2 ? key('BACK') : null}
187 </Box>
188 ))}
189 </Box>
190 )
191}
192
193const resultLine = (today: Today) =>
194 today.state === 'won' ? `Solved in ${today.guesses.length}/6` : `The word was ${(today.answer ?? '').toUpperCase()}`
195
196function statsLine(stats: Stats) {
197 const win = stats.played ? Math.round((stats.won / stats.played) * 100) : 0
198
199 return `Played ${stats.played} Win ${win}% Streak ${stats.streak} Best ${stats.bestStreak}`
200}
201
202function statsBlock(s: Surf, today: Today, stats: Stats | null) {
203 const { Box, Text } = s.elements
204 const result = resultLine(today)
205 if (!stats) return <Text bold>{result}</Text>
206 const most = Math.max(1, ...stats.distribution)
207 const winRow = today.state === 'won' ? today.guesses.length - 1 : -1
208
209 return (
210 <Box flexDirection="column">
211 <Text bold>{result}</Text>
212 <Text>{statsLine(stats)}</Text>
213 {stats.distribution.map((n, i) => (
214 <Box flexDirection="row" gap={1}>
215 <Text>{String(i + 1)}</Text>
216 {n > 0 ? <Text color={i === winRow ? GREEN : GRAY}>{'█'.repeat(Math.max(1, Math.round((n / most) * BAR_W)))}</Text> : null}
217 <Text>{String(n)}</Text>
218 </Box>
219 ))}
220 </Box>
221 )
222}
223
224function boardLine(r: Board['rows'][number], period: Period) {
225 const login = r.login.length > LOGIN_W ? r.login.slice(0, LOGIN_W - 1) + '~' : r.login
226 const score = period === 'today' ? (r.guesses ?? 'X') : (r.points ?? '-')
227
228 return `${String(r.rank).padStart(3)} ${login.padEnd(LOGIN_W)} ${String(score).padStart(7)} ${String(r.played).padStart(6)}`
229}
230
231// `rows` is how many lines the board may take, tabs and column names included.
232function boardBlock(live: Live, s: Surf, board: Board | null, rows: number) {
233 const { Box, Text } = s.elements
234 const me = live.props.me
235 const period = PERIODS[live.tab]!
236 const tabs = (
237 <Box flexDirection="row" gap={2}>
238 {PERIODS.map((p, i) => (
239 <Text inverse={i === live.tab} bold={i === live.tab}>
240 {` ${TAB[p]} `}
241 </Text>
242 ))}
243 </Box>
244 )
245 if (!board || board.period !== period) return <Box flexDirection="column">{tabs}<Text dimColor>Loading...</Text></Box>
246 const isIn = board.rows.some(r => r.login === me)
247 const showYou = board.you !== null && !isIn && rows >= 5
248 const top = Math.max(0, Math.min(20, rows - 2 - (showYou ? 2 : 0)))
249
250 return (
251 <Box flexDirection="column">
252 {tabs}
253 <Text dimColor>{`${'#'.padStart(3)} ${'player'.padEnd(LOGIN_W)} ${(period === 'today' ? 'guesses' : 'points').padStart(7)} ${'played'.padStart(6)}`}</Text>
254 {board.rows.slice(0, top).map(r => (
255 <Text bold={r.login === me}>{boardLine(r, period)}</Text>
256 ))}
257 {showYou ? <Text dimColor>{' ...'}</Text> : null}
258 {showYou ? <Text bold>{boardLine(board.you!, period)}</Text> : null}
259 </Box>
260 )
261}
262
263function shareLine(s: Surf, copied: View['copied']) {
264 const { Box, Text } = s.elements
265
266 return (
267 <Box flexDirection="row" gap={2}>
268 <Text dimColor>s share arrows switch board r reload</Text>
269 {copied === 'ok' ? <Text color={GREEN}>Copied</Text> : null}
270 {copied === 'failed' ? <Text color="red">Copy failed</Text> : null}
271 </Box>
272 )
273}
274
275function draw(live: Live, s: Surf) {
276 const { Box, Text } = s.elements
277 const { today, notice, stats, board, copied } = live.props
278 const noticeLine = notice ? <Text color="yellow">{notice}</Text> : null
279 if (today === null) {
280 return (
281 <Box flexDirection="column" alignItems="center" paddingY={1}>
282 <Text bold>DAILY DIFF</Text>
283 <Text>{notice ?? 'Loading...'}</Text>
284 <Text dimColor>r retry</Text>
285 </Box>
286 )
287 }
288 const done = isDone(today)
289 const noticeRows = notice ? 2 : 0
290 // 0 before the first layout: the region is sized by the hooks, so draw the full screen.
291 const room = s.rows > 0 ? s.rows : Infinity
292 const hasTiles = done && s.columns >= TILES_W && room >= TILES_ROWS
293 const width = hasTiles ? TILES_W : s.columns > 0 ? Math.min(WIDE, s.columns) : WIDE
294 const header = (
295 <Box flexDirection="row" justifyContent="space-between" width={done ? width : 41}>
296 <Text bold>{`DAILY DIFF #${today.number}`}</Text>
297 <Text dimColor>{`next in ${countdown(timeLeft(live, today))}`}</Text>
298 </Box>
299 )
300 if (!done) {
301 const isSmall = room < PLAY_ROWS + noticeRows
302 const need = SMALL_PLAY_ROWS + noticeRows
303 if (s.columns > 0 && (s.columns < MIN_COLUMNS || room < need)) {
304 return (
305 <Box paddingX={1}>
306 <Text color="yellow">{`Make the pane bigger to play Daily Diff (${MIN_COLUMNS} x ${need} at least).`}</Text>
307 </Box>
308 )
309 }
310
311 return (
312 <Box flexDirection="column" alignItems="center" gap={1}>
313 {header}
314 {isSmall ? smallGrid(live, s, today) : grid(live, s, today)}
315 {keyboard(s, today)}
316 {noticeLine}
317 </Box>
318 )
319 }
320 if (hasTiles) {
321 // Beside the tiles: stats, the board, share, and the gaps between them.
322 const fixed = 2 + (stats ? 8 : 1) + 1 + 2 + noticeRows
323
324 return (
325 <Box flexDirection="column" alignItems="center" gap={1}>
326 {header}
327 <Box flexDirection="row" gap={2} width={TILES_W}>
328 {grid(live, s, today, 0)}
329 <Box flexDirection="column" gap={1} width={RESULTS_W}>
330 {statsBlock(s, today, stats)}
331 {noticeLine}
332 {room - fixed >= 3 ? boardBlock(live, s, board, room - fixed) : null}
333 {shareLine(s, copied)}
334 </Box>
335 </Box>
336 </Box>
337 )
338 }
339 // Short or narrow: the result, stats and share come first and the board gets what is left.
340 if (room < DONE_ROWS + noticeRows || width < WIDE) {
341 const fixed = 1 + 1 + (stats ? 1 : 0) + (notice ? 1 : 0) + 1
342
343 return (
344 <Box flexDirection="column" width={width}>
345 {header}
346 <Text bold>{resultLine(today)}</Text>
347 {stats ? <Text>{statsLine(stats)}</Text> : null}
348 {noticeLine}
349 {shareLine(s, copied)}
350 {room - fixed >= 3 ? boardBlock(live, s, board, room - fixed) : null}
351 </Box>
352 )
353 }
354 // Header, the grid and stats (8, or 11 with the grid's rows spaced), board, share, and the gaps between them.
355 const isSpaced = room >= DONE_ROWS + 3 + noticeRows
356 const fixed = 1 + (isSpaced ? 11 : 8) + 1 + 3 + noticeRows
357
358 return (
359 <Box flexDirection="column" alignItems="center" gap={1}>
360 {header}
361 <Box flexDirection="row" gap={2} width={WIDE}>
362 {smallGrid(live, s, today, isSpaced ? 1 : 0)}
363 {statsBlock(s, today, stats)}
364 </Box>
365 {noticeLine}
366 <Box width={WIDE}>{boardBlock(live, s, board, room - fixed)}</Box>
367 <Box width={WIDE}>{shareLine(s, copied)}</Box>
368 </Box>
369 )
370}
371
372const Game: ClientModule<View, Shell> = (props, surface) => {
373 let shell = surface.state
374 if (shell === undefined) {
375 const live: Live = { props, rev: 0, t: 0, typed: '', flashUntil: 0, tab: 0, seenRejected: props.rejected, seenGuesses: guessKey(props.today), seenPeriod: null, seenNow: props.now, nowAt: 0, askedNext: 0 }
376 shell = { live, rev: 0 }
377 const commit = () => surface.setState({ live, rev: ++live.rev })
378 surface.every(TICK_MS, () => {
379 const wasFlashing = live.t < live.flashUntil
380 live.t += TICK_MS
381 if ((wasFlashing && live.t >= live.flashUntil) || live.t % 1000 === 0) commit()
382 const { today } = live.props
383 if (isDone(today) && today!.number !== live.askedNext && timeLeft(live, today!) <= 0) {
384 live.askedNext = today!.number
385 surface.post({ type: 'retry' })
386 }
387 })
388 surface.onKey(e => {
389 if (e.ctrl || e.meta) return
390 onKey(live, e.key, m => surface.post(m))
391 commit()
392 })
393 surface.setState(shell)
394 }
395 const { live } = shell
396 sync(live, props)
397
398 return draw(live, surface)
399}
400
401export default Game
402types/index.d.ts 27 lines1export type Period = 'today' | 'week' | 'month' | 'all'
2export type GuessRow = { word: string; marks: string }
3export type Today = { number: number; day: string; endsAt: number; guesses: GuessRow[]; state: 'playing' | 'won' | 'lost'; answer: string | null }
4export type BoardRow = { rank: number; login: string; points: number | null; guesses: number | null; played: number; ms: number }
5export type Board = { period: Period; rows: BoardRow[]; you: BoardRow | null }
6export type Stats = { played: number; won: number; streak: number; bestStreak: number; distribution: number[] }
7export type View = {
8 me: string | null
9 notice: string | null
10 today: Today | null
11 board: Board | null
12 stats: Stats | null
13 isSending: boolean
14 // Bumped on each rejected word so the Client flashes the row once per rejection.
15 rejected: number
16 copied: 'ok' | 'failed' | null
17 // $.clock.now() when today last loaded, so the Client's countdown runs on the engine's clock.
18 now: number
19}
20export type ClientMsg = { type: 'guess'; word: string } | { type: 'board'; period: Period } | { type: 'share' } | { type: 'retry' }
21
22declare module 'claude-code' {
23 interface PluginState {
24 'daily-diff': { view: View }
25 }
26}
27