SLOPSHOPPER

daily-diff

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

newpanecommandnetworktimer
v0.1.2MITupdated 2026-10-08jpo-oss/claude-games/plugins/daily-diff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · daily-diff
│ ┃ Daily Diff ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./game.tsx │ ⏺ 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 │ │ › /cg-daily-diff │ ⎿ daily-diff: Daily Diff open. Click the pane to type; Esc closes │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Daily Diff
▣ client module ./game.tsx
README

Daily Diff

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.

Sign-in

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.

What it sends over the network

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.

  • Sign-in: the game asks github.com for a code and polls until you approve it. The GitHub token goes to the game server once, which checks it with GitHub and replies with a session key.
  • Playing: each guess you make, and requests for today's puzzle, your stats and the leaderboard.

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.

What it does in your session

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.

What else is in this folder

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.

License

MIT

Source 6 files
hooks/register.tsx 300 lines
1import { 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}
300
hooks/auth.ts 96 lines
1import { 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}
96
hooks/net.ts 133 lines
1import 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}
133
hooks/text.ts 34 lines
1import 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}
34
hooks/game.tsx 402 lines
1import 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
402
types/index.d.ts 27 lines
1export 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