Memory pairs in a pane. Sixteen face-down cards, eight matching pairs; press a card's letter to turn it, find its twin, finish in as few turns and as little…

Memory pairs in a pane beside the conversation. Sixteen cards lie face down, eight matching pairs. Press a card's letter to turn it, find its twin, and clear the board in as few turns, and as little time, as you can. Your fewest turns and fastest time are kept.
It's a game you play, not a watcher: it never calls a model, never reads what Claude is doing, and costs no tokens. A good one for a child at the keyboard while a long turn runs.

Recorded in a terminal. The Desktop app draws the same pane, with each key shown as a badge.
PAIRS turns 5 found 2/8 time 0:41 best 11 turns, 1:12 · game 3
Not a pair. They turn back in a moment.
@ b c #
e * g h
@ j k #
m + o p
r: New deal t: How to play Esc closes
The cards are colored, which the text above can't show: a card that is up sits on yellow, a found pair on green, and face-down cards show their key dim. On the Desktop app each face-down card is a button with its letter as a key badge; in a terminal the letter is the label.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install pairs@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/pairs
/pairs. The pane opens with sixteen face-down cards. A fresh deal shows a short how-to until your first card; t hides or shows it any time.The keys work while the pane has the focus. If pressing a letter types into the prompt instead, click the pane first.
| Command | What it does |
|---|---|
/pairs | Open the pane. Where no pane can be drawn, print how far the deal has got. |
/pairs new | Deal a new game. |
/pairs stats | Games dealt, games finished, fewest turns, fastest time. |
From claude plugin validate ./mods/pairs:
hooks: session.start, classic.SessionStart{source=clear|resume|fork}, command.run{command=pairs}, ui.close{id=pairs}, ui.render{component=Pane}
calls: $.clock.after (via scheduleHide), $.clock.every (via startTicking), $.clock.now, $.command.register, $.session.surfaces, $.state.get, $.state.set, $.store.get (via load), $.store.set (via save), $.ui.invalidate, $.ui.log, $.ui.open, $.ui.resolve, $.ui.toast
$.store keeps the deal, which cards are up, and the record, so a game survives a restart. A mismatched pair left up across a restart turns back down on load. If the store can't be read at start, the mod plays in memory and writes nothing over the saved record.$.clock seeds each deal, times the game, turns a mismatched pair back down after 0.9 seconds, and redraws the clock once a second while a game is running in an open pane. Both timers stop on a new deal and when the pane closes; the clock also stops at the last pair.ui.close is observed to stop both timers, turn the pair down, and pause the clock.claude plugin validate --strict and claude plugin test passing: the shuffle, turning, a miss and a match, finishing with the fewest-turns record, the clock starting, pausing and stopping, mounted pane tests for the keys, the hide pause and the running clock on the mock clock, a third card cutting the pause short, the status lines, the commands, an unreadable store, and a narrow pane. The test kit can't fire ui.close, so the pair turning down on close is covered by the load path.MIT, see the repository root.
hooks/register.ts 287 lines1// pairs: memory pairs in a pane.
2//
3// /pairs open the game
4// /pairs new deal a new game
5// /pairs stats games, finished, fewest turns, fastest time
6//
7// In the pane: each face-down card shows a letter, a to p; press it to turn
8// the card. Turn two the same and they stay up; two different turn back after
9// a moment. The clock runs from the first card to the last pair and stops while
10// the pane is closed. r deals again, t shows the how-to. Nothing here calls a model,
11// reads what Claude is doing, or leaves the machine.
12
13import { atom, read, update } from 'claude-code'
14import type { Game, Stats } from '../types'
15import { CARDS, FACES, KEYS, SIZE, elapsed, formatMs, hideMiss, isGame, newGame, pairsFound, pause, turn } from './game'
16
17const PANE = 'pairs'
18const HIDE_MS = 900
19const EMPTY_STATS: Stats = { games: 0, finished: 0, bestTurns: 0, bestMs: 0 }
20const EMPTY: Game = { seed: 0, faces: [], matched: [], up: [], turns: 0, done: false, elapsedMs: 0, startedAt: null, stats: EMPTY_STATS }
21const game = atom({ plugin: 'pairs', key: 'game' } as const, EMPTY)
22
23// Turns a mismatched pair back down after a moment. Dies with the module on reload.
24let hiding: { cancel: () => void } | null = null
25// Redraws the running clock once a second while the pane is open and a game is on.
26let ticking: { cancel: () => void } | null = null
27// The how-to block: shown on a fresh deal until the first card, or until t
28// has set it either way for the session.
29let help: boolean | null = null
30// False after the store could not be read: then nothing is written over the
31// saved record until a later load reads it.
32let storeOk = true
33
34async function save($, g: Game) {
35 if (!storeOk) return
36 try {
37 await $.store.set('game', g)
38 } catch {
39 // The drawing still updates; the store catches up on the next change.
40 }
41}
42
43async function change($, fn: (cur: Game) => Game): Promise<Game> {
44 let out: Game | null = null
45 await update($, game, (cur) => {
46 out = fn(cur)
47 return out
48 })
49 // The empty default is never written over a saved deal.
50 if (out && (out as Game).seed) await save($, out)
51 return out ?? (await read($, game))
52}
53
54async function load($) {
55 let saved: unknown = undefined
56 let readable = true
57 try {
58 saved = await $.store.get('game')
59 } catch {
60 readable = false
61 }
62 storeOk = readable
63 // A deal saved by 0.1.0 has no clock fields; give it a stopped clock and no
64 // fastest time rather than throwing the record away.
65 let migrated = false
66 if (saved && typeof saved === 'object') {
67 const s = saved as Record<string, unknown>
68 const stats = s.stats as Record<string, unknown> | undefined
69 migrated = !('elapsedMs' in s) || !(stats && typeof stats === 'object' && 'bestMs' in stats)
70 saved = { elapsedMs: 0, startedAt: null, ...s, stats: stats && typeof stats === 'object' ? { bestMs: 0, ...stats } : stats }
71 }
72 if (isGame(saved)) {
73 // A pair left up across a restart turns back down, and the clock waits for
74 // the next card: a stretch cut off by a restart isn't counted.
75 const g = hideMiss({ ...(saved as Game), startedAt: null })
76 await update($, game, () => g)
77 if (migrated) await save($, g)
78 return
79 }
80 if (!readable) {
81 const cur = await read($, game)
82 if (!cur.seed) await update($, game, () => newGame(Date.now(), { ...EMPTY_STATS, games: 1 }))
83 $.ui.log('pairs could not read its saved game; playing without saving')
84 return
85 }
86 const fresh = newGame(await $.clock.now(), { ...EMPTY_STATS, games: 1 })
87 await update($, game, () => fresh)
88 await save($, fresh)
89}
90
91function stopHiding() {
92 if (hiding) hiding.cancel()
93 hiding = null
94}
95
96function stopTicking() {
97 if (ticking) ticking.cancel()
98 ticking = null
99}
100
101// The clock in the header only changes once a second, so a tick asks for a
102// redraw rather than changing the game; it stops itself when nothing is running.
103function startTicking($) {
104 if (ticking) return
105 ticking = $.clock.every(1000, async () => {
106 try {
107 const cur = await read($, game)
108 if (cur.done || cur.startedAt === null) {
109 stopTicking()
110 return
111 }
112 $.ui.invalidate('ui.render')
113 } catch {
114 // A missed tick just leaves the clock a second behind.
115 }
116 })
117}
118
119function scheduleHide($) {
120 stopHiding()
121 hiding = $.clock.after(HIDE_MS, async () => {
122 hiding = null
123 try {
124 await change($, hideMiss)
125 } catch {
126 // The pair stays up until the next turn hides it.
127 }
128 })
129}
130
131async function startNew($) {
132 stopHiding()
133 stopTicking()
134 const now = await $.clock.now()
135 return change($, (cur) => newGame(now, { ...cur.stats, games: cur.stats.games + 1 }))
136}
137
138function summary(g: Game, now: number): string {
139 if (!g.seed) return 'No game yet.'
140 const time = formatMs(elapsed(g, now))
141 const state = g.done ? 'All found in ' + g.turns + ' turns and ' + time + '.' : pairsFound(g) + ' of ' + CARDS / 2 + ' pairs found, ' + g.turns + ' turns, ' + time + '.'
142 return 'Pairs: ' + state + (g.stats.bestTurns ? ' Best ' + g.stats.bestTurns + ' turns, ' + formatMs(g.stats.bestMs) + '.' : '') + ' /pairs opens the pane.'
143}
144
145export function register(on) {
146 on('session.start', async ($, e, next) => {
147 help = null
148 try {
149 await load($)
150 } catch (error) {
151 $.ui.log('pairs could not load its game: ' + error)
152 }
153 try {
154 await $.command.register({ name: 'pairs', description: 'Play memory pairs in a pane', argumentHint: '[new | stats]', immediate: true })
155 } catch (error) {
156 $.ui.log('could not register /pairs: ' + error)
157 }
158 return next(e)
159 })
160
161 // /clear and /resume reset $.state; the game lives on in the store.
162 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
163 stopHiding()
164 stopTicking()
165 await load($)
166 return next(e)
167 }).catch(async ($, e, next) => next(e))
168
169 on('command.run', { command: 'pairs' }, async ($, e) => {
170 const args = e.args.trim().toLowerCase()
171 if (args === 'new') return { text: 'New deal. ' + summary(await startNew($), await $.clock.now()) }
172 if (args === 'stats') {
173 const s = (await read($, game)).stats
174 return { text: 'Games ' + s.games + ' · finished ' + s.finished + ' · fewest turns ' + (s.bestTurns || '—') + ' · fastest ' + (s.bestMs ? formatMs(s.bestMs) : '—') }
175 }
176 if (args) return { text: 'Usage: /pairs, /pairs new, or /pairs stats' }
177 // Under claude -p nothing draws, and ui.open still reports the pane as placed:
178 // the surfaces list is the reliable sign.
179 let surfaces = []
180 try {
181 surfaces = await $.session.surfaces()
182 } catch {
183 surfaces = []
184 }
185 if (!surfaces.length) return { text: summary(await read($, game), await $.clock.now()) }
186 const placed = await $.ui.open({ id: PANE, title: 'Pairs', focus: true, closeOnEscape: true, rows: 22 })
187 if (!placed.isPlaced) return { text: summary(await read($, game), await $.clock.now()) }
188 // A game paused by closing the pane picks its clock up at the next card.
189 return {}
190 }).catch(async () => ({ text: 'pairs: the command failed.' }))
191
192 // Closing the pane turns a mismatched pair down at once and stops the clock.
193 on('ui.close', { id: PANE }, async ($, e, next) => {
194 stopHiding()
195 stopTicking()
196 const now = await $.clock.now()
197 await change($, (cur) => pause(hideMiss(cur), now))
198 return next(e)
199 }).catch(async ($, e, next) => next(e))
200
201 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
202 if (e.requestId !== PANE) return next(e)
203 const g = await read($, game)
204 const { Box, Text, Button } = $.ui.resolve(e)
205 if (!g.seed) return Text({ children: ['No game loaded. /pairs new starts one.'] })
206 const columns = e.props.bodyColumns || 60
207 const wide = columns >= 50
208 const showHelp = help ?? (wide && g.turns === 0 && !g.up.length)
209 const desktop = e.surface === 'desktop'
210 const found = pairsFound(g)
211 const now = await $.clock.now()
212 const time = formatMs(elapsed(g, now))
213 if (!g.done && g.startedAt !== null) startTicking($)
214
215 const status = g.done ? { text: 'All eight pairs in ' + g.turns + ' turns and ' + time + (g.stats.bestTurns === g.turns && g.stats.bestMs === g.elapsedMs ? ', your best on both. ' : g.stats.bestMs === g.elapsedMs ? ', your fastest. ' : g.stats.bestTurns === g.turns ? ', your fewest turns. ' : '. ') + 'Press r for a new deal.', color: 'success', bold: true }
216 : g.up.length === 2 ? { text: 'Not a pair. They turn back in a moment.', color: 'warning' }
217 : g.up.length === 1 ? { text: 'Now find its twin.', color: 'suggestion' }
218 : g.turns === 0 ? { text: 'Press a card\'s letter to turn it over.', color: 'suggestion' }
219 : { text: found + ' of 8 pairs found. Turn a card.', color: 'suggestion' }
220
221 const header = Box({ flexDirection: 'column', children: [
222 Box({ flexDirection: 'row', columnGap: 2, flexWrap: 'wrap', children: [
223 Text({ bold: true, children: ['PAIRS'] }),
224 Text({ children: ['turns ', Text({ bold: true, children: [String(g.turns)] })] }),
225 Text({ children: ['found ' + found + '/8'] }),
226 Text({ bold: true, color: g.done ? 'success' : g.startedAt !== null ? 'warning' : undefined, dimColor: g.startedAt === null && !g.done, children: ['time ' + time] }),
227 Text({ dimColor: true, children: ['best ' + (g.stats.bestTurns ? g.stats.bestTurns + ' turns, ' + formatMs(g.stats.bestMs) : '—') + ' · game ' + g.stats.games] }),
228 ] }),
229 Text({ color: status.color, bold: status.bold, wrap: 'wrap', children: [status.text] }),
230 ] })
231
232 // Every card is a fixed-width Box so the grid lines up on any surface. A
233 // face-down card shows its key (the desktop draws the key as a badge, so the
234 // label is blank there); a card that is up shows its face on yellow, a found
235 // pair its face on green.
236 const card = (i: number) => {
237 const isUp = g.up.includes(i)
238 const shown = g.matched[i] || isUp
239 const backgroundColor = g.matched[i] ? 'success' : isUp ? 'warning' : undefined
240 const label = shown ? FACES[g.faces[i]] : desktop ? ' ' : KEYS[i]
241 return Box({ width: 5, justifyContent: 'center', backgroundColor, children: [
242 Button({ key: 'card-' + i, label, hotkey: shown ? undefined : KEYS[i], dimColor: !shown, onPress: async () => {
243 let error: string | undefined
244 let outcome: string | undefined
245 const pressedAt = await $.clock.now()
246 const after = await change($, (cur) => {
247 const r = turn(cur, i, pressedAt)
248 error = r.error
249 outcome = r.outcome
250 return r.game
251 })
252 if (error) {
253 $.ui.toast(error)
254 return
255 }
256 // A stale timer is harmless: hideMiss only acts on a mismatched pair.
257 if (outcome === 'miss') scheduleHide($)
258 if (after.done) {
259 stopTicking()
260 $.ui.toast('All pairs found in ' + after.turns + ' turns and ' + formatMs(after.elapsedMs) + '!', { timeoutMs: 5000 })
261 }
262 } }),
263 ] })
264 }
265 const grid = Box({ flexDirection: 'column', children: Array.from({ length: SIZE }, (_, r) => Box({ flexDirection: 'row', columnGap: 1, children: Array.from({ length: SIZE }, (_, c) => card(r * SIZE + c)) })) })
266
267 const howTo = Box({ flexDirection: 'column', borderStyle: 'round', borderColor: 'suggestion', paddingX: 1, children: [
268 Text({ bold: true, children: ['How to play'] }),
269 Text({ wrap: 'wrap', children: ['1. Sixteen cards lie face down, eight matching pairs. Press a card\'s letter, a to p, or click it, to turn it over.'] }),
270 Text({ wrap: 'wrap', children: ['2. Turn a second card. A pair stays up; two different cards turn back after a moment. Remember where they were.'] }),
271 Text({ wrap: 'wrap', children: ['3. Find all eight pairs in as few turns, and as little time, as you can. The clock runs from your first card and pauses while the pane is closed. Your best of each is kept.'] }),
272 Text({ dimColor: true, wrap: 'wrap', children: ['Keys need the pane focused: click it if nothing happens. t hides this.'] }),
273 ] })
274
275 const footer = Box({ flexDirection: 'row', columnGap: 3, flexWrap: 'wrap', children: [
276 Button({ key: 'new', label: 'New deal', hotkey: 'r', plain: true, onPress: () => startNew($) }),
277 Button({ key: 'help', label: showHelp ? 'Hide help' : 'How to play', hotkey: 't', plain: true, dimColor: true, onPress: () => {
278 help = !showHelp
279 $.ui.invalidate('ui.render')
280 } }),
281 Text({ dimColor: true, children: ['Esc closes'] }),
282 ] })
283
284 return Box({ flexDirection: 'column', rowGap: 1, children: showHelp ? [header, grid, howTo, footer] : [header, grid, footer] })
285 })
286}
287hooks/game.ts 114 lines1// The rules of memory pairs, pure: no `$` here, so the tests and the hooks
2// module share one implementation.
3
4import type { Game, Stats } from '../types'
5
6export const SIZE = 4
7export const CARDS = SIZE * SIZE
8// Eight faces, plain ASCII so they draw one cell wide everywhere.
9export const FACES = ['@', '#', '$', '%', '&', '*', '+', '=']
10// The key that turns each card, in reading order.
11export const KEYS = 'abcdefghijklmnop'.split('')
12
13function hash(s: string): number {
14 let h = 2166136261
15 for (let i = 0; i < s.length; i++) {
16 h ^= s.charCodeAt(i)
17 h = Math.imul(h, 16777619)
18 }
19 return h >>> 0
20}
21
22function rng(seed: number): () => number {
23 let a = seed
24 return () => {
25 a = (a + 0x6d2b79f5) | 0
26 let t = Math.imul(a ^ (a >>> 15), 1 | a)
27 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
28 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
29 }
30}
31
32export function shuffle(seed: number): number[] {
33 const faces = Array.from({ length: CARDS }, (_, i) => i % FACES.length)
34 const r = rng(hash(seed + ':deal'))
35 for (let i = faces.length - 1; i > 0; i--) {
36 const j = Math.floor(r() * (i + 1))
37 ;[faces[i], faces[j]] = [faces[j], faces[i]]
38 }
39 return faces
40}
41
42export function newGame(seed: number, stats: Stats): Game {
43 return { seed, faces: shuffle(seed), matched: new Array(CARDS).fill(false), up: [], turns: 0, done: false, elapsedMs: 0, startedAt: null, stats }
44}
45
46export type TurnResult = { game: Game; error?: string; outcome?: 'first' | 'match' | 'miss' }
47
48// Time played so far: earlier stretches plus the running one.
49export function elapsed(g: Game, now: number): number {
50 return g.elapsedMs + (g.startedAt !== null && !g.done ? Math.max(0, now - g.startedAt) : 0)
51}
52
53// Stop the clock, folding the running stretch into the total: for a closed pane.
54export function pause(g: Game, now: number): Game {
55 if (g.startedAt === null || g.done) return g
56 return { ...g, elapsedMs: elapsed(g, now), startedAt: null }
57}
58
59export function formatMs(ms: number): string {
60 const s = Math.floor(ms / 1000)
61 return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0')
62}
63
64// Turn a card. Two mismatched cards left up are turned back by this call first.
65// The clock starts at the first card and stops at the last pair.
66export function turn(g: Game, card: number, now: number): TurnResult {
67 if (g.done) return { game: g, error: 'All pairs found. Press r for a new game.' }
68 if (!Number.isInteger(card) || card < 0 || card >= CARDS) return { game: g, error: 'No such card.' }
69 if (g.matched[card]) return { game: g, error: 'That pair is already found.' }
70 let up = g.up.length === 2 ? [] : g.up
71 if (up.includes(card)) return { game: g, error: 'That card is already up.' }
72 const startedAt = g.startedAt ?? now
73 if (!up.length) return { game: { ...g, up: [card], startedAt }, outcome: 'first' }
74 const first = up[0]
75 const turns = g.turns + 1
76 if (g.faces[first] === g.faces[card]) {
77 const matched = g.matched.slice()
78 matched[first] = true
79 matched[card] = true
80 const done = matched.every(Boolean)
81 if (!done) return { game: { ...g, matched, up: [], turns, startedAt }, outcome: 'match' }
82 // At least a millisecond, since 0 means no time recorded.
83 const ms = Math.max(1, elapsed({ ...g, startedAt }, now))
84 const stats: Stats = {
85 ...g.stats,
86 finished: g.stats.finished + 1,
87 bestTurns: g.stats.bestTurns ? Math.min(g.stats.bestTurns, turns) : turns,
88 bestMs: g.stats.bestMs ? Math.min(g.stats.bestMs, ms) : ms,
89 }
90 return { game: { ...g, matched, up: [], turns, done: true, elapsedMs: ms, startedAt: null, stats }, outcome: 'match' }
91 }
92 return { game: { ...g, up: [first, card], turns, startedAt }, outcome: 'miss' }
93}
94
95// Turn a mismatched pair back down.
96export function hideMiss(g: Game): Game {
97 return g.up.length === 2 ? { ...g, up: [] } : g
98}
99
100export function pairsFound(g: Game): number {
101 return g.matched.filter(Boolean).length / 2
102}
103
104export function isGame(x: unknown): x is Game {
105 if (!x || typeof x !== 'object') return false
106 const g = x as Record<string, unknown>
107 const stats = g.stats as Record<string, unknown> | undefined
108 return typeof g.seed === 'number' && Array.isArray(g.faces) && g.faces.length === CARDS && g.faces.every((f) => Number.isInteger(f) && f >= 0 && f < FACES.length) &&
109 Array.isArray(g.matched) && g.matched.length === CARDS && g.matched.every((m) => typeof m === 'boolean') &&
110 Array.isArray(g.up) && g.up.length <= 2 && g.up.every((c) => Number.isInteger(c) && c >= 0 && c < CARDS) &&
111 Number.isInteger(g.turns) && typeof g.done === 'boolean' && Number.isInteger(g.elapsedMs) && (g.startedAt === null || typeof g.startedAt === 'number') &&
112 !!stats && typeof stats === 'object' && ['games', 'finished', 'bestTurns', 'bestMs'].every((k) => Number.isInteger(stats[k]))
113}
114types/index.d.ts 26 lines1export type Stats = { games: number; finished: number; bestTurns: number; bestMs: number }
2export type Game = {
3 /** Seed of this game's shuffle; 0 until a game exists. */
4 seed: number
5 /** The face under each of the 16 cards, 0 to 7, each twice. */
6 faces: number[]
7 /** Cards whose pair has been found. */
8 matched: boolean[]
9 /** Cards face up and not yet matched: none, one, or a mismatched two. */
10 up: number[]
11 /** Pairs of cards turned so far. */
12 turns: number
13 done: boolean
14 /** Time played before the current stretch, in ms. */
15 elapsedMs: number
16 /** When the current stretch of play began; null while the pane is closed or before the first card. */
17 startedAt: number | null
18 stats: Stats
19}
20
21declare module 'claude-code' {
22 interface PluginState {
23 pairs: { game: Game }
24 }
25}
26