Falling-block puzzle inside Claude Code: solo Marathon, a bot to beat, 1v1 battles and a leaderboard while a long task runs

A falling-block puzzle you play inside Claude Code while it works on something long. Play solo Marathon, battle other players 1v1 online, and climb the leaderboard. It draws in the terminal and in the Code tab of Claude Desktop.
Run /cg-block-battle and click the pane to start. Left and right move, down drops faster, space drops all the way. Up or x rotates, z rotates the other way, a flips, c holds. p pauses Marathon, q goes back to the menu, Esc leaves the pane.
Marathon works offline, and when you are not signed in it sends nothing. Everything else talks to one game server: the official one at https://games.jpoapps.com, or an address you type when you pick Battle. The game only uses HTTPS (plain HTTP only for a server on your own machine, for testing).
The game stores, on your machine through Claude Code: the session key for each server you signed in to, and the last server address you typed. It sends a session key only to the server it came from.
The server code is open source at https://github.com/jpo-oss/claude-games-server. It stores your GitHub login, numeric ID and account creation date, scores and battle results, and never logs tokens, request bodies or IP addresses. Full details are in the privacy policy.
The game adds one command, /cg-block-battle, and runs no other commands, tools or programs. It listens to three session events only to time one reminder: when you send a prompt and when Claude's turn ends, it can show a single toast after two minutes ("Long task. /cg-block-battle while you wait?"), and while Claude is asking you a question with AskUserQuestion it waits so the toast never covers the question. It passes each of these events on unchanged and never reads, stores or sends what you or Claude wrote.
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, tool calls and commands) so the game can be tested without a network or a real session; the game itself makes none of those calls beyond the ones listed above.
MIT
hooks/register.tsx 719 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, HttpInit, Register, Timer } from 'claude-code'
3
4import type { Battle, BotLevel, ClientMsg, GameView } from '../types'
5import { parseConfig, parseSession, pollToken, requestDeviceCode, sessionKey } from './auth'
6import type { Session } from './auth'
7import { OPP_COLUMNS, TIERS } from './draw'
8import {
9 call,
10 emptyOutbox,
11 mergeIncoming,
12 nextPayload,
13 parseClientMsg,
14 parseLeaderboard,
15 parseMarathonStart,
16 parseQueue,
17 parseSync,
18 queueAttacks,
19 serverUrlOk,
20} from './net'
21import type { Outbox, Reply } from './net'
22import { takeChunk } from './log'
23import type { Assembly, LogMsg } from './log'
24
25const PANE = 'block-battle'
26const NUDGE_MS = 120_000
27const NUDGE_RETRY_MS = 10_000
28const QUEUE_POLL_MS = 1_500
29const QUEUE_GIVE_UP_MS = 120_000
30
31export const idleBattle = (): Battle => ({ status: 'idle', roomId: null, seed: 0, opponent: null, incoming: [], result: null, online: null })
32const OFFICIAL = 'https://games.jpoapps.com'
33const LAST_SERVER = 'lastServer'
34const startView = (): GameView => ({
35 me: null,
36 leaderboard: null,
37 notice: null,
38 battle: idleBattle(),
39 servers: { home: OFFICIAL, last: null, active: OFFICIAL },
40 marathon: null,
41 vsbot: null,
42 uploaded: null,
43})
44
45const view = atom({ plugin: 'block-battle', key: 'view' } as const, startView())
46
47const DOWN = 'Game server unreachable. Solo play still works.'
48const SIGNED_OUT = 'Signed out. Pick Battle or Leaderboard to sign in again.'
49const OUTDATED = 'Block Battle is out of date. Run claude plugin update block-battle@claude-games in your shell, then /reload-plugins.'
50const SIGNING_IN = 'Signing in with GitHub...'
51const NO_SIGNIN = "This server isn't set up for sign-in."
52const BAD_URL = 'The server address must start with https://.'
53const TIMEOUT_MS = 30_000
54const NO_GITHUB = "Couldn't reach GitHub to sign in. Solo play still works."
55const CANCELLED = 'Sign-in was cancelled or expired. Pick Battle or Leaderboard to try again.'
56const signInLine = (uri: string, code: string) => {
57 const line = `Sign in: open ${uri} and enter ${code}`
58 if (rt.base === OFFICIAL) return line
59
60 return `Community server ${new URL(rt.base).host}, run by someone else. It will learn your GitHub username. ${line}`
61}
62const trimSlash = (u: string) => u.replace(/\/+$/, '')
63
64type Owned = { key: string; base: string }
65
66const rt: {
67 base: string
68 hasLoadedLast: boolean
69 // Our server's session key, with the server it belongs to. The GitHub token is never kept: it is exchanged and dropped.
70 session: { base: string; s: Session } | null
71 signIn: Timer | null
72 isSigningIn: boolean
73 // Bumped when the pane closes so a flow still waiting on a request stops when it resumes.
74 signInGen: number
75 signInLine: string | null
76 queueTimer: Timer | null
77 // Bumped on every battle start, stop, reset, server switch and match. An async flow keeps the value it
78 // started with and writes nothing once it has moved on.
79 battleGen: number
80 // The generation whose queue poll is in flight, so a stale one never holds up the next search.
81 polling: number | null
82 isSyncing: boolean
83 outbox: Outbox
84 latest: { snapshot: string; isOver: boolean }
85 opponentLogin: string
86 // The login whose session made the current match; the result is judged against it.
87 myLogin: string
88 lastSyncOk: number
89 isAsking: boolean
90 nudge: Timer | null
91 isTurnOn: boolean
92 wasNudged: boolean
93 // Logs are only taken for marathon and bot game ids the server handed out and rooms we were matched in,
94 // and go to the server that handed them out.
95 games: Owned[]
96 botGames: Owned[]
97 rooms: Owned[]
98 asm: Assembly | null
99 sentKeys: string[]
100 wantsSync: boolean
101 retry: Timer | null
102} = {
103 base: OFFICIAL,
104 hasLoadedLast: false,
105 session: null,
106 signIn: null,
107 isSigningIn: false,
108 signInGen: 0,
109 signInLine: null,
110 queueTimer: null,
111 battleGen: 0,
112 polling: null,
113 isSyncing: false,
114 outbox: emptyOutbox(),
115 latest: { snapshot: '', isOver: false },
116 opponentLogin: '',
117 myLogin: '',
118 lastSyncOk: 0,
119 isAsking: false,
120 nudge: null,
121 isTurnOn: false,
122 wasNudged: false,
123 games: [],
124 botGames: [],
125 rooms: [],
126 asm: null,
127 sentKeys: [],
128 wantsSync: false,
129 retry: null,
130}
131
132const setView = ($: EngineInterface, patch: Partial<GameView>) => update($, view, v => ({ ...v, ...patch }))
133const isLive = (gen: number) => gen === rt.battleGen
134// Battle writes recheck their generation inside the update: the SDK can hold a write after the caller's own check.
135const setViewIf = ($: EngineInterface, gen: number, patch: Partial<GameView>) =>
136 update($, view, v => (isLive(gen) ? { ...v, ...patch } : v))
137const setBattleIf = ($: EngineInterface, gen: number, patch: Partial<Battle>) =>
138 update($, view, v => (isLive(gen) ? { ...v, battle: { ...v.battle, ...patch } } : v))
139
140function noticeFor(r: { status: number; error: string }): string {
141 if (r.status === 0) return DOWN
142 if (r.status === 426) return OUTDATED
143 if (r.status === 401 || r.status === 403) return SIGNED_OUT
144
145 return `Server said: ${r.error}`
146}
147
148// The server can change while the store answers, so the session is only ever returned for the base it was read for.
149// The name shown follows the active server's session on every call, so a sign-in cut short by a close still shows it.
150async function loadSession($: EngineInterface, base = rt.base): Promise<Session | null> {
151 const loaded = rt.session?.base === base ? rt.session.s : parseSession(await $.store.get(sessionKey(base)))
152 if (loaded === null || base !== rt.base) return loaded
153 const s = rt.session?.base === base ? rt.session.s : loaded
154 rt.session = { base, s }
155 const { me } = await read($, view)
156 if (me !== s.login) await update($, view, v => (base === rt.base && rt.session?.s === s ? { ...v, me: s.login } : v))
157
158 return s
159}
160
161function stopSignIn() {
162 rt.signIn?.cancel()
163 rt.signIn = null
164 rt.isSigningIn = false
165 rt.signInLine = null
166 rt.signInGen++
167}
168
169// Returns the battle generation to carry on with, or null once something newer has taken over.
170async function useServer($: EngineInterface, url: string, gen: number): Promise<number | null> {
171 const next = trimSlash(url)
172 if (next !== OFFICIAL) {
173 await $.store.set(LAST_SERVER, next)
174 await update($, view, v => ({ ...v, servers: { ...v.servers, last: next } }))
175 }
176 if (!isLive(gen)) return null
177 if (next === rt.base) return gen
178 stopSignIn()
179 rt.base = next
180 rt.session = null
181 const switched = bump()
182 await update($, view, v => (isLive(switched) ? { ...v, me: null, leaderboard: null, servers: { ...v.servers, active: next } } : v))
183
184 return isLive(switched) ? switched : null
185}
186
187async function loadLast($: EngineInterface) {
188 const last = await $.store.get(LAST_SERVER)
189 if (typeof last === 'string' && serverUrlOk(last)) await update($, view, v => ({ ...v, servers: { ...v.servers, last } }))
190}
191
192// Forgets only the session that was sent: a newer one saved meanwhile stays.
193async function dropSession($: EngineInterface, base: string, sent: string) {
194 if (rt.session?.base === base && rt.session.s.session === sent) rt.session = null
195 if (parseSession(await $.store.get(sessionKey(base)))?.session === sent) {
196 await $.store.delete(sessionKey(base))
197 if (rt.session?.base === base) await $.store.set(sessionKey(base), rt.session.s)
198 }
199 await update($, view, v => (base === rt.base && rt.session?.base !== base ? { ...v, me: null } : v))
200}
201
202async function signOut($: EngineInterface): Promise<string> {
203 const base = rt.base
204 const s = await loadSession($, base)
205 if (s === null) return 'Not signed in to this server.'
206 if (serverUrlOk(base)) {
207 await call((url, init) => $.http.fetch(url, init), base, 'DELETE', '/v1/session', s.session, undefined, signal =>
208 $.clock.sleep(TIMEOUT_MS, { signal }),
209 )
210 }
211 await dropSession($, base, s.session)
212
213 return 'Signed out of Block Battle.'
214}
215
216async function startSignIn($: EngineInterface) {
217 if (rt.isSigningIn) return
218 rt.isSigningIn = true
219 const base = rt.base
220 const gen = ++rt.signInGen
221 const live = () => gen === rt.signInGen
222 const end = (notice: string) => {
223 if (!live()) return
224 rt.isSigningIn = false
225 rt.signIn = null
226 rt.signInLine = null
227
228 return setView($, { notice })
229 }
230 const fetch = (url: string, init?: HttpInit) => $.http.fetch(url, init)
231 const giveUp = (signal: AbortSignal) => $.clock.sleep(TIMEOUT_MS, { signal })
232 try {
233 const config = await call(fetch, base, 'GET', '/v1/config', null, undefined, giveUp)
234 if (!live()) return
235 if (!config.ok) return end(noticeFor(config))
236 const clientId = parseConfig(config.data)
237 if (!clientId) return end(NO_SIGNIN)
238 const code = await requestDeviceCode(fetch, clientId, giveUp)
239 if (!live()) return
240 if (!code) return end(NO_GITHUB)
241 rt.signInLine = signInLine(code.uri, code.userCode)
242 await setView($, { leaderboard: null, notice: rt.signInLine })
243 const startedAt = await $.clock.now()
244 let interval = code.interval
245 const tick = async () => {
246 try {
247 if (!live()) return
248 if ((await $.clock.now()) - startedAt > code.expiresIn * 1000) return end(CANCELLED)
249 const poll = await pollToken(fetch, clientId, code.deviceCode, giveUp)
250 if (!live()) return
251 if (poll.kind === 'pending' || poll.kind === 'slowDown') {
252 if (poll.kind === 'slowDown') interval += 5
253 rt.signIn = $.clock.after(interval * 1000, () => void tick())
254
255 return
256 }
257 if (poll.kind === 'failed') return end(poll.reason === 'error' ? NO_GITHUB : CANCELLED)
258 await setView($, { notice: SIGNING_IN })
259 if (!live()) return
260 const r = await call(fetch, base, 'POST', '/v1/session', null, { githubToken: poll.token }, signal => $.clock.sleep(TIMEOUT_MS, { signal }))
261 if (!live()) return
262 const s = r.ok ? parseSession(r.data) : null
263 if (!s) return end(r.ok ? DOWN : noticeFor(r))
264 rt.session = { base, s }
265 await $.store.set(sessionKey(base), s)
266 if (!live() || base !== rt.base) return
267 await update($, view, v => (live() && base === rt.base && rt.session?.s === s ? { ...v, me: s.login } : v))
268 await end(`Signed in as ${s.login}.`)
269 void prefetchBest($)
270 } catch {
271 await end(NO_GITHUB)
272 }
273 }
274 rt.signIn = $.clock.after(interval * 1000, () => void tick())
275 } catch {
276 await end(NO_GITHUB)
277 }
278}
279
280// A server other than the active one is only called with its own stored session, never with a sign-in.
281// isWanted is asked once the session is in hand, right before anything is sent.
282async function send(
283 $: EngineInterface,
284 method: 'GET' | 'POST' | 'DELETE',
285 path: string,
286 body?: unknown,
287 base = rt.base,
288 isWanted = () => true,
289): Promise<{ r: Reply<unknown>; login: string | null }> {
290 if (!serverUrlOk(base)) return { r: { ok: false, status: -2, error: BAD_URL }, login: null }
291 const s = await loadSession($, base)
292 if (!isWanted()) return { r: { ok: false, status: -3, error: '' }, login: null }
293 if (s === null) {
294 if (base !== rt.base) return { r: { ok: false, status: -1, error: SIGNED_OUT }, login: null }
295 void startSignIn($)
296 return { r: { ok: false, status: -1, error: rt.signInLine ?? SIGNING_IN }, login: null }
297 }
298 const r = await call((url, init) => $.http.fetch(url, init), base, method, path, s.session, body, signal => $.clock.sleep(TIMEOUT_MS, { signal }))
299 if (!r.ok && r.status === 401) await dropSession($, base, s.session)
300
301 return { r, login: s.login }
302}
303
304async function api(
305 $: EngineInterface,
306 method: 'GET' | 'POST' | 'DELETE',
307 path: string,
308 body?: unknown,
309 base = rt.base,
310 isWanted = () => true,
311): Promise<Reply<unknown>> {
312 return (await send($, method, path, body, base, isWanted)).r
313}
314
315function resultFor(winner: string): 'win' | 'loss' | null {
316 if (rt.myLogin !== '' && winner === rt.myLogin) return 'win'
317 if (winner === rt.opponentLogin) return 'loss'
318
319 return null
320}
321
322const fail = (r: { status: number; error: string }) => (r.status < 0 ? r.error : noticeFor(r))
323
324async function loadLeaderboard($: EngineInterface) {
325 const r = await api($, 'GET', '/v1/leaderboard')
326 const board = r.ok ? parseLeaderboard(r.data) : null
327 if (board) await setView($, { leaderboard: board, notice: null })
328 else await setView($, { leaderboard: null, notice: r.ok ? DOWN : fail(r) })
329}
330
331// For the menu's Marathon best only: it never touches the notice line, which a slow reply
332// would otherwise overwrite after the person has moved on (a matchmaking error, say).
333async function prefetchBest($: EngineInterface) {
334 const base = rt.base
335 if ((await loadSession($, base)) === null) return
336 const r = await api($, 'GET', '/v1/leaderboard', undefined, base)
337 const board = r.ok ? parseLeaderboard(r.data) : null
338 if (board && base === rt.base) await setView($, { leaderboard: board })
339}
340
341// A new generation: whatever timers the old one left are stopped with it.
342function bump(): number {
343 rt.queueTimer?.cancel()
344 rt.queueTimer = null
345 rt.retry?.cancel()
346 rt.retry = null
347
348 return ++rt.battleGen
349}
350
351// Returns the generation it set up, or null once something newer has taken over.
352async function resetBattle($: EngineInterface, isLeaving: boolean): Promise<number | null> {
353 const wasQueueing = rt.queueTimer !== null
354 const base = rt.base
355 const gen = bump()
356 rt.outbox = emptyOutbox()
357 rt.latest = { snapshot: '', isOver: false }
358 rt.opponentLogin = ''
359 rt.myLogin = ''
360 await setBattleIf($, gen, { online: null })
361 if (isLeaving && wasQueueing) await api($, 'DELETE', '/v1/battle/queue', undefined, base)
362 if (!isLive(gen)) return null
363 await setBattleIf($, gen, idleBattle())
364
365 return isLive(gen) ? gen : null
366}
367
368async function pollQueue($: EngineInterface, startedAt: number, gen: number) {
369 if (rt.polling === gen || !isLive(gen)) return
370 rt.polling = gen
371 const base = rt.base
372 const live = () => isLive(gen)
373 try {
374 const now = await $.clock.now()
375 if (!live()) return
376 if (now - startedAt > QUEUE_GIVE_UP_MS) {
377 const reset = await resetBattle($, true)
378 if (reset !== null) await setViewIf($, reset, { notice: 'No opponent found. Try again in a moment.' })
379
380 return
381 }
382 const { r, login } = await send($, 'POST', '/v1/battle/queue', undefined, base, live)
383 // A match made for a search the player has left is ignored: the server ends it by forfeit once nobody syncs,
384 // and until then answers this search with 409, which is retried.
385 if (!live()) return
386 if (!r.ok) {
387 if (r.status === 409) return
388 const stopped = bump()
389 await setBattleIf($, stopped, { online: null })
390 if (r.status !== 401 && r.status !== -1) await api($, 'DELETE', '/v1/battle/queue', undefined, base)
391 await setBattleIf($, stopped, idleBattle())
392 await setViewIf($, stopped, { notice: fail(r) })
393
394 return
395 }
396 const q = parseQueue(r.data)
397 if (q?.status === 'waiting') await setBattleIf($, gen, { online: q.online })
398 if (q?.status !== 'matched' || login === null) return
399 const matchedAt = await $.clock.now()
400 if (!live()) return
401 const matched = bump()
402 rt.opponentLogin = q.opponent
403 rt.myLogin = login
404 rt.rooms = [...rt.rooms, { key: q.roomId, base }].slice(-8)
405 rt.lastSyncOk = matchedAt
406 // Room for the big board and the opponent's beside it; a width the person dragged still wins.
407 $.ui.open({ id: PANE, title: 'Block Battle', columns: TIERS.big.columns + OPP_COLUMNS + 1, rows: 46 }).catch(() => undefined)
408 await setBattleIf($, matched, {
409 status: 'matched',
410 roomId: q.roomId,
411 seed: q.seed,
412 opponent: { login: q.opponent, snapshot: '', isOver: false },
413 incoming: [],
414 result: null,
415 online: null,
416 })
417 } finally {
418 if (rt.polling === gen) rt.polling = null
419 }
420}
421
422async function startQueue($: EngineInterface) {
423 const gen = bump()
424 await setViewIf($, gen, { notice: null })
425 if (!isLive(gen)) return
426 await setBattleIf($, gen, { status: 'queueing', online: null })
427 if (!isLive(gen)) return
428 const startedAt = await $.clock.now()
429 if (!isLive(gen)) return
430 rt.queueTimer = $.clock.every(QUEUE_POLL_MS, () => void pollQueue($, startedAt, gen))
431 void pollQueue($, startedAt, gen)
432}
433
434async function flushSync($: EngineInterface) {
435 if (rt.isSyncing) return
436 const gen = rt.battleGen
437 const base = rt.base
438 const live = () => isLive(gen)
439 const { battle } = await read($, view)
440 const room = battle.roomId
441 if (!live() || rt.isSyncing || room === null || battle.status !== 'matched') return
442 rt.isSyncing = true
443 try {
444 const again = (ms: number) => {
445 rt.retry?.cancel()
446 rt.retry = $.clock.after(ms, () => void flushSync($))
447 }
448 const end = (notice: string) => endMatch($, gen, notice)
449 const timedOut = async () => {
450 const now = await $.clock.now()
451 return live() && now - rt.lastSyncOk >= TIMEOUT_MS
452 }
453 // After topping out the Client stops posting syncs, so the hooks keep asking until the result comes or the server has been silent too long.
454 const stalled = async () => {
455 if (!rt.latest.isOver) return
456 if (await timedOut()) await end(DOWN)
457 else if (live()) again(1_000)
458 }
459 const { seq, attacks } = nextPayload(rt.outbox)
460 const path = `/v1/battle/${encodeURIComponent(room)}/sync`
461 const r = await api($, 'POST', path, { seq, attacks, snapshot: rt.latest.snapshot, isOver: rt.latest.isOver }, base)
462 if (!live()) return
463 if (!r.ok) {
464 if (r.status === 404 || r.status === 403) await end('The match is no longer available.')
465 else if (r.status === 401) await end(fail(r))
466 else if (r.status === 0 && !rt.latest.isOver) {
467 if (await timedOut()) await end(DOWN)
468 } else await stalled()
469
470 return
471 }
472 rt.outbox.inflight = null
473 const s = parseSync(r.data)
474 if (!s) return stalled()
475 const now = await $.clock.now()
476 if (!live()) return
477 rt.lastSyncOk = now
478 if (!s.ended && rt.latest.isOver) again(250)
479 const result = s.ended && s.winner !== null ? resultFor(s.winner) : null
480 if (s.ended && result === null) return end('The match ended with no result.')
481 await update($, view, v => {
482 if (!live() || v.battle.roomId !== room) return v
483
484 return {
485 ...v,
486 battle: {
487 ...v.battle,
488 opponent: s.opponent ?? v.battle.opponent,
489 incoming: mergeIncoming(v.battle.incoming, s.incoming),
490 ...(s.ended ? { status: 'ended' as const, result } : {}),
491 },
492 }
493 })
494 } finally {
495 rt.isSyncing = false
496 if (rt.wantsSync) {
497 rt.wantsSync = false
498 void flushSync($)
499 }
500 }
501}
502
503async function endMatch($: EngineInterface, gen: number, notice: string) {
504 if (!isLive(gen)) return
505 const reset = await resetBattle($, false)
506 if (reset !== null) await setViewIf($, reset, { notice })
507}
508
509async function startMarathon($: EngineInterface, nonce: number) {
510 const gen = await resetBattle($, true)
511 if (gen === null) return
512 await setViewIf($, gen, { notice: null })
513 if (!isLive(gen)) return
514 let gameId: string | null = null
515 let seed = 0
516 const base = rt.base
517 const isSignedIn = serverUrlOk(base) && (await loadSession($, base)) !== null
518 if (!isLive(gen)) return
519 if (isSignedIn) {
520 const r = await api($, 'POST', '/v1/marathon', undefined, base)
521 if (!isLive(gen)) return
522 const g = r.ok ? parseMarathonStart(r.data) : null
523 if (g) {
524 ;({ gameId, seed } = g)
525 rt.games = [...rt.games, { key: g.gameId, base }].slice(-8)
526 }
527 }
528 await setViewIf($, gen, { marathon: { nonce, gameId, seed } })
529}
530
531// A ranked game nobody will play still holds one of the 5 open games until it is sent.
532function closeBotGame($: EngineInterface, base: string, gameId: string) {
533 rt.sentKeys = [...rt.sentKeys, gameId].slice(-8)
534
535 return sendLog($, base, '/v1/bot/scores', { gameId, log: { steps: 1, inputs: [] } }, 3, 5_000)
536}
537
538async function startBot($: EngineInterface, level: BotLevel, nonce: number) {
539 const gen = await resetBattle($, true)
540 if (gen === null) return
541 await setViewIf($, gen, { notice: null })
542 if (!isLive(gen)) return
543 // Room for both boards; a width the person dragged still wins.
544 $.ui.open({ id: PANE, title: 'Block Battle', columns: TIERS.big.columns + OPP_COLUMNS + 1, rows: 46 }).catch(() => undefined)
545 let gameId: string | null = null
546 let seed = 0
547 const base = rt.base
548 const isSignedIn = serverUrlOk(base) && (await loadSession($, base)) !== null
549 if (!isLive(gen)) return
550 if (isSignedIn) {
551 const r = await api($, 'POST', '/v1/bot', { level }, base)
552 const g = r.ok ? parseMarathonStart(r.data) : null
553 if (g && !isLive(gen)) return void closeBotGame($, base, g.gameId)
554 if (!isLive(gen)) return
555 if (g) {
556 ;({ gameId, seed } = g)
557 rt.botGames = [...rt.botGames, { key: g.gameId, base }].slice(-8)
558 }
559 }
560 await setViewIf($, gen, { vsbot: { nonce, level, gameId, seed } })
561}
562
563// Retries only when the server could not answer (status 0) or said 503.
564async function sendLog($: EngineInterface, base: string, path: string, body: unknown, tries: number, waitMs: number): Promise<Reply<unknown>> {
565 for (let i = 1; ; i++) {
566 const r = await api($, 'POST', path, body, base)
567 if (r.ok || (r.status !== 0 && r.status !== 503) || i >= tries) return r
568 await $.clock.sleep(waitMs)
569 }
570}
571
572async function takeLog($: EngineInterface, m: LogMsg) {
573 const owners = m.kind === 'marathon' ? rt.games : m.kind === 'bot' ? rt.botGames : rt.rooms
574 const owner = owners.find(o => o.key === m.key)
575 if (!owner) return
576 const r = takeChunk(rt.asm, m)
577 rt.asm = r.asm
578 await setView($, { uploaded: { key: m.key, have: r.have } })
579 if (!r.log || rt.sentKeys.includes(m.key)) return
580 rt.sentKeys = [...rt.sentKeys, m.key].slice(-8)
581 if (m.kind === 'battle') {
582 // The server waits 30 s for the winner's log.
583 await sendLog($, owner.base, `/v1/battle/${encodeURIComponent(m.key)}/log`, { log: r.log }, 5, 3_000)
584 return
585 }
586 const path = m.kind === 'marathon' ? '/v1/scores' : '/v1/bot/scores'
587 const res = await sendLog($, owner.base, path, { gameId: m.key, log: r.log }, 3, 5_000)
588 const board = res.ok ? parseLeaderboard(res.data) : null
589 if (board && owner.base === rt.base) await setView($, { leaderboard: board })
590}
591
592async function handle($: EngineInterface, m: ClientMsg) {
593 if (m.type === 'menu') {
594 if (m.choice === 'leaderboard') return loadLeaderboard($)
595 if (m.choice === 'battle') {
596 const gen = await resetBattle($, true)
597 if (gen === null || (await useServer($, m.server, gen)) === null) return
598
599 return startQueue($)
600 }
601 if (m.choice === 'marathon') return startMarathon($, m.nonce)
602 if (m.choice === 'bot') return startBot($, m.level, m.nonce)
603 await resetBattle($, true)
604
605 return
606 }
607 if (m.type === 'log') return takeLog($, m)
608 if (m.type !== 'sync') return
609 const gen = rt.battleGen
610 const { battle } = await read($, view)
611 if (!isLive(gen) || battle.status !== 'matched') return
612 rt.latest = { snapshot: m.snapshot, isOver: m.isOver }
613 queueAttacks(rt.outbox, m.attacks)
614 if (rt.isSyncing) {
615 rt.wantsSync = true
616
617 return
618 }
619
620 return flushSync($)
621}
622
623// The nudge: one toast per Claude turn that has run two minutes, never over a question dialog.
624function tryNudge($: EngineInterface) {
625 rt.nudge = null
626 if (!rt.isTurnOn || rt.wasNudged) return
627 if (rt.isAsking) {
628 rt.nudge = $.clock.after(NUDGE_RETRY_MS, () => tryNudge($))
629
630 return
631 }
632 rt.wasNudged = true
633 $.ui.toast('Long task. /cg-block-battle while you wait?', { timeoutMs: 8_000 })
634}
635
636export const register: Register = on => {
637 rt.base = OFFICIAL
638
639 on('session.start', async ($, e, next) => {
640 await $.command.register({ name: 'cg-block-battle', description: 'Play Block Battle while Claude works. Add "signout" to sign out' })
641
642 return next(e)
643 })
644
645 on('command.run', { command: 'cg-block-battle' }, async ($, e) => {
646 if (e.args.trim() === 'signout') return { text: await signOut($) }
647 await resetBattle($, true)
648 await setView($, { notice: null })
649 const opened = await $.ui.open({ id: PANE, title: 'Block Battle', focus: true, closeOnEscape: true, holdToasts: true, columns: 66, rows: 46 })
650 if (!opened.isPlaced) return { text: `Block Battle could not be shown here: ${opened.reason}` }
651 // The menu shows the Marathon best, so fetch it now rather than only when Leaderboard is chosen.
652 void prefetchBest($)
653 // The engine refuses $.ui.focus on a Client; only the person's click gives it the keys.
654 return { text: 'Block Battle open. Click the pane to play; q returns to the menu, Esc closes it.' }
655 })
656
657 on('ui.close', async ($, e, next) => {
658 if (e.id === PANE) {
659 stopSignIn()
660 await resetBattle($, true)
661 }
662
663 return next(e)
664 })
665
666 on('ui.message', async ($, e, next) => {
667 if (e.element !== 'game') return next(e)
668 const m = parseClientMsg(e.data)
669 if (m) void handle($, m)
670
671 return next(e)
672 })
673
674 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
675 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
676 const ui = $.ui.resolve(e)
677 if (!rt.hasLoadedLast) {
678 rt.hasLoadedLast = true
679 void loadLast($).catch(() => undefined)
680 }
681 const stored = await read($, view)
682 const v = { ...stored, servers: { ...stored.servers, active: rt.base } }
683
684 // Without a size the region is as tall as what the module draws, so a one-line "too small"
685 // note would measure the region as one line and keep it there.
686 return <ui.Client key="game" module="./game.tsx" props={v} width={e.props.bodyColumns} height={e.props.scroll.bodyRows} />
687 })
688
689 on('prompt.submit', async ($, e, next) => {
690 if (!rt.isTurnOn) {
691 rt.isTurnOn = true
692 rt.wasNudged = false
693 rt.nudge?.cancel()
694 rt.nudge = $.clock.after(NUDGE_MS, () => tryNudge($))
695 }
696
697 return next(e)
698 })
699
700 on('turn.complete', async ($, e, next) => {
701 if (e.agentId === undefined) {
702 rt.isTurnOn = false
703 rt.nudge?.cancel()
704 rt.nudge = null
705 }
706
707 return next(e)
708 })
709
710 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
711 rt.isAsking = true
712 try {
713 return await next(e)
714 } finally {
715 rt.isAsking = false
716 }
717 })
718}
719hooks/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/draw.ts 299 lines1import { cellsOf, ghostY } from './engine'
2import type { Active, Cell, Game, Kind } from './engine'
3
4export const COLORS: Record<Kind | 'G', string> = {
5 I: '#00e5ff',
6 O: '#ffd400',
7 T: '#b44cff',
8 S: '#38d64a',
9 Z: '#ff3b3b',
10 J: '#3b6bff',
11 L: '#ff9a1f',
12 G: '#808080',
13}
14
15// The piece colour faded toward the dark board, so ghost and trail are solid blocks rather than
16// shade characters, which most terminal fonts draw as a sparse, broken-looking dot pattern.
17export function fade(hex: string, amount: number): string {
18 const n = parseInt(hex.slice(1), 16)
19 const mix = (c: number, bg: number) => Math.round(bg + (c - bg) * amount)
20 const r = mix((n >> 16) & 255, 0x26)
21 const g = mix((n >> 8) & 255, 0x26)
22 const b = mix(n & 255, 0x26)
23
24 return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')
25}
26
27export const CLEAR_MS = 250
28export const FLASH_MS = 125
29export const TRAIL_MS = 160
30export const LABEL_MS = 1100
31export const LEVEL_MS = 600
32export const GARBAGE_MS = 350
33export const FILL_ROW_MS = 35
34// Past this the fill is full and the label is up: nothing about the game-over screen changes any more.
35export const OVER_FINAL_MS = 700
36
37export const overStep = (overMs: number, dtMs: number): number => (overMs > OVER_FINAL_MS ? overMs : overMs + dtMs)
38
39export type Label = { text: string; color: string; age: number }
40export type Fx = {
41 clear: { rows: number[]; age: number } | null
42 trail: { cols: number[]; from: number; to: number; kind: Kind; age: number } | null
43 labels: Label[]
44 levelUp: number | null
45 garbage: { lines: number; age: number } | null
46}
47export const emptyFx = (): Fx => ({ clear: null, trail: null, labels: [], levelUp: null, garbage: null })
48
49export type Style = { fg?: string; bg?: string; dim?: true; bold?: true; inverse?: true }
50export type Ch = { c: string } & Style
51export type Run = { text: string } & Style
52
53const BOARD_H = 40
54const VISIBLE = 20
55export const FIELD_W = 10
56
57// Layouts, widest first. A tier needs both numbers; below the last one the pane says it is too small.
58// Columns: board column (1 garbage-bar column + framed board) + 1 gap + side column. Battle adds the opponent's board.
59export type Tier = 'big' | 'medium' | 'mini' | 'tiny'
60export const TIERS = {
61 big: { cw: 4, ch: 2, columns: 66, rows: 44, side: 22 },
62 medium: { cw: 2, ch: 1, columns: 48, rows: 25, side: 24 },
63 mini: { cw: 2, ch: 1, columns: 32, rows: 22, side: 10 },
64} as const
65export const OPP_COLUMNS = 13
66// 0 means the region has not been laid out yet (the first frame): draw the smallest thing that cannot overflow.
67export function pickTier(columns: number, rows: number, isBattle: boolean): Tier {
68 const extra = isBattle ? OPP_COLUMNS : 0
69 for (const t of ['big', 'medium'] as const) if (columns >= TIERS[t].columns + extra && rows >= TIERS[t].rows) return t
70 // mini without its side column still needs 21 wide
71 if (columns >= 21 && rows >= TIERS.mini.rows) return 'mini'
72
73 return 'tiny'
74}
75
76const same = (a: Style, b: Style) => a.fg === b.fg && a.bg === b.bg && a.dim === b.dim && a.bold === b.bold && a.inverse === b.inverse
77
78export function runs(row: readonly Ch[]): Run[] {
79 const out: Run[] = []
80 let last: Ch | undefined
81 for (const ch of row) {
82 const { c, ...style } = ch
83 if (last && same(last, ch)) out[out.length - 1]!.text += c
84 else out.push({ text: c, ...style })
85 last = ch
86 }
87
88 return out
89}
90
91const blocks = (text: string, style: Style): Ch[] => [...text].map(c => ({ c, ...style }))
92
93// The engine collapses cleared rows at once; the flash needs them back where they were.
94// New board = n empty rows on top + the survivors, so survivors + full rows at the old indices is the old board.
95export function reconstruct(board: readonly (readonly Cell[])[], rows: readonly number[]): ((Cell | 'F')[])[] | null {
96 const sorted = [...rows].sort((a, b) => a - b)
97 if (sorted.length === 0 || sorted.some((r, i) => !Number.isInteger(r) || r < 0 || r >= BOARD_H || sorted[i - 1] === r)) return null
98 const out: (Cell | 'F')[][] = board.slice(sorted.length).map(r => [...r])
99 for (const r of sorted) out.splice(r, 0, new Array<'F'>(FIELD_W).fill('F'))
100
101 return out.length === BOARD_H ? out : null
102}
103
104export type Line = { text: string; fg?: string; bold?: true; dim?: true; bg?: string }
105
106// A framed box of text as characters: rounded border in `border`, one centred line per entry.
107export function boxChars(lines: readonly Line[], width: number, border: string, bg: string): Ch[][] {
108 const inner = Math.max(0, width - 2)
109 const edge = { fg: border, bg }
110 const rows: Ch[][] = [blocks('╭' + '─'.repeat(inner) + '╮', edge)]
111 for (const l of lines) {
112 const t = l.text.length > inner ? l.text.slice(0, inner) : l.text
113 const left = Math.floor((inner - t.length) / 2)
114 const style: Style = { bg: l.bg ?? bg, ...(l.fg ? { fg: l.fg } : {}), ...(l.bold ? { bold: true as const } : {}), ...(l.dim ? { dim: true as const } : {}) }
115 rows.push([...blocks('│', edge), ...blocks(' '.repeat(left) + t + ' '.repeat(inner - left - t.length), style), ...blocks('│', edge)])
116 }
117 rows.push(blocks('╰' + '─'.repeat(inner) + '╯', edge))
118
119 return rows
120}
121
122const POPUP_BG = '#0b0b12'
123
124// The label as a bordered pill: lit solid when it pops in, plain while it lives, dim as it leaves.
125export function labelBox(label: Label, width: number): Ch[][] {
126 const w = Math.min(width, label.text.length + 4)
127 const line: Line =
128 label.age < 120 ? { text: label.text, fg: '#000000', bg: label.color, bold: true } : { text: label.text, fg: label.color, bold: true, ...(label.age > LABEL_MS * 0.6 ? { dim: true as const } : {}) }
129 const box = boxChars([line], w, label.color, POPUP_BG)
130 if (label.age > LABEL_MS * 0.6) for (const r of box) for (const c of r) c.dim = true
131
132 return box
133}
134
135export type Overlay = { title: string; color: string; lines: readonly string[] }
136
137export function overlayBox(o: Overlay, width: number): Ch[][] {
138 const lines: Line[] = [{ text: '' }, { text: o.title, fg: o.color, bold: true }, { text: '' }, ...o.lines.map((text): Line => ({ text, fg: '#d0d0d8' })), { text: '' }]
139 const w = Math.min(width, Math.max(...lines.map(l => l.text.length)) + 4)
140
141 return boxChars(lines, w, o.color, POPUP_BG)
142}
143
144export function formatMs(ms: number): string {
145 const cs = Math.floor(ms / 10)
146
147 return `${Math.floor(cs / 6000)}:${String(Math.floor(cs / 100) % 60).padStart(2, '0')}.${String(cs % 100).padStart(2, '0')}`
148}
149
150export function vsOverlay(won: boolean, steps: number, ranked: boolean): Overlay {
151 return { title: won ? 'YOU WIN' : 'YOU LOSE', color: won ? '#38d64a' : '#ff3b3b', lines: [won ? `TIME ${formatMs(steps * 16)}` : '', ranked ? '' : 'unranked', 'q menu'] }
152}
153
154const EMPTY_A = '#14141b'
155const EMPTY_B = '#191922'
156
157type Spec = { k: 'solid'; color: string } | { k: 'empty'; x: number; y: number } | { k: 'flash'; color: string } | { k: 'thin' } | { k: 'gap' }
158
159// One text row of one cell; solid cells are one flat colour in every size.
160function rasterCell(s: Spec, cw: number, ch: number, r: number): Ch[] {
161 if (s.k === 'solid') return blocks('█'.repeat(cw), { fg: s.color })
162 if (s.k === 'flash') return blocks('█'.repeat(cw), { fg: s.color, bold: true })
163 if (s.k === 'thin') return r === ch - 1 ? blocks('▂'.repeat(cw), { fg: '#ffffff', dim: true }) : blocks(' '.repeat(cw), {})
164 if (s.k === 'gap') return blocks(' '.repeat(cw), {})
165
166 return blocks(' '.repeat(cw), { bg: (s.x + s.y) % 2 === 0 ? EMPTY_A : EMPTY_B })
167}
168
169export type Size = { cw: number; ch: number }
170export const COMPACT: Size = { cw: 2, ch: 1 }
171
172// Stamp a box of characters onto the grid, clipped to it.
173function stamp(rows: Ch[][], box: readonly Ch[][], x0: number, y0: number) {
174 box.forEach((line, i) => {
175 const row = rows[y0 + i]
176 if (!row) return
177 line.forEach((c, j) => {
178 if (x0 + j >= 0 && x0 + j < row.length) row[x0 + j] = c
179 })
180 })
181}
182
183export function fieldRows(game: Game, fx: Fx, extra: readonly Label[], overMs: number, size: Size = COMPACT, overlay: Overlay | null = null): Ch[][] {
184 const { cw, ch } = size
185 let display: (Cell | 'F')[][] = game.board.map(r => [...r])
186 if (fx.clear) display = reconstruct(game.board, fx.clear.rows) ?? display
187 const clearAge = fx.clear?.age ?? 0
188 const specs: Spec[][] = display.slice(BOARD_H - VISIBLE).map((r, y) =>
189 r.map((cell, x): Spec => {
190 if (cell === 'F') {
191 if (clearAge < FLASH_MS) return { k: 'flash', color: Math.floor(clearAge / 40) % 2 === 0 ? '#ffffff' : '#9fe8ff' }
192 if (clearAge < 190) return { k: 'thin' }
193
194 return { k: 'gap' }
195 }
196 if (cell === null) return { k: 'empty', x, y }
197 if (cell === 'G') {
198 const isRising = fx.garbage && y >= VISIBLE - fx.garbage.lines && fx.garbage.age < GARBAGE_MS
199
200 return { k: 'solid', color: isRising ? '#ff6b6b' : COLORS.G }
201 }
202
203 return { k: 'solid', color: COLORS[cell] }
204 }),
205 )
206 const put = (x: number, y: number, color: string) => {
207 const row = specs[y - (BOARD_H - VISIBLE)]
208 if (row && x >= 0 && x < FIELD_W) row[x] = { k: 'solid', color }
209 }
210 const trail = () => {
211 if (!fx.trail || fx.trail.age >= TRAIL_MS) return
212 const { cols, from, to, kind } = fx.trail
213 for (const x of cols) for (let y = from; y < to; y++) put(x, y, fade(COLORS[kind], 0.15))
214 }
215
216 if (game.active && !game.isOver) {
217 const a: Active = game.active
218 const gy = ghostY(game)
219 if (gy !== null) for (const [x, y] of cellsOf({ ...a, y: gy })) put(x, y, fade(COLORS[a.kind], 0.28))
220 trail()
221 for (const [x, y] of cellsOf(a)) put(x, y, COLORS[a.kind])
222 } else trail()
223
224 // Game over: the pieces drain to their faded tint from the bottom up, and the board stays readable.
225 if (overMs > 0) {
226 const filled = Math.min(VISIBLE, Math.floor(overMs / FILL_ROW_MS))
227 for (let y = VISIBLE - filled; y < VISIBLE; y++) {
228 specs[y] = specs[y]!.map(s => (s.k === 'solid' ? { k: 'solid', color: fade(s.color, 0.3) } : s))
229 }
230 }
231
232 const rows: Ch[][] = specs.flatMap(row => Array.from({ length: ch }, (_, r) => row.flatMap(s => rasterCell(s, cw, ch, r))))
233 const width = FIELD_W * cw
234 const pills = [...extra, ...[...fx.labels].reverse()].slice(0, 4)
235 pills.forEach((label, i) => {
236 const box = labelBox(label, width)
237 stamp(rows, box, Math.floor((width - (box[0]?.length ?? 0)) / 2), 4 * ch + i * 3)
238 })
239 if (overlay) {
240 const box = overlayBox(overlay, width)
241 stamp(rows, box, Math.floor((width - (box[0]?.length ?? 0)) / 2), Math.floor((rows.length - box.length) / 2))
242 }
243
244 return rows
245}
246
247// A piece at rotation 0 centred in a `width`-wide block, two piece rows tall, in the given cell size.
248export function previewRows(kind: Kind, size: Size, width: number, isDim = false): Ch[][] {
249 const { cw, ch } = size
250 const cells = cellsOf({ kind, rotation: 0, x: 0, y: 0 })
251 const minX = Math.min(...cells.map(([x]) => x))
252 const maxX = Math.max(...cells.map(([x]) => x))
253 const minY = Math.min(...cells.map(([, y]) => y))
254 const left = Math.floor((width - (maxX - minX + 1) * cw) / 2)
255 const rows: Ch[][] = Array.from({ length: 2 * ch }, () => blocks(' '.repeat(width), {}))
256 for (const [x, y] of cells) {
257 for (let r = 0; r < ch; r++) {
258 const row = rows[(y - minY) * ch + r]
259 if (row) row.splice(left + (x - minX) * cw, cw, ...rasterCell({ k: 'solid', color: COLORS[kind] }, cw, ch, r).map(c => (isDim ? { ...c, dim: true as const } : c)))
260 }
261 }
262
263 return rows
264}
265
266export const blankRows = (n: number, width: number): Ch[][] => Array.from({ length: n }, () => blocks(' '.repeat(width), {}))
267
268const KINDS = 'IOTSZJL'
269
270// One character per cell: the opponent's 20 x 10 visible board as the server relays it.
271export function snapshotRows(snapshot: string): Ch[][] {
272 const flat = [...snapshot.replace(/\s+/g, '')]
273 const rows: Ch[][] = []
274 for (let y = 0; y < VISIBLE; y++) {
275 const row: Ch[] = []
276 for (let x = 0; x < FIELD_W; x++) {
277 const c = flat[y * FIELD_W + x] ?? '.'
278 if (c === '.' || c === '0' || c === '-' || c === '_') row.push({ c: ' ', bg: (x + y) % 2 === 0 ? EMPTY_A : EMPTY_B })
279 else row.push({ c: '█', fg: KINDS.includes(c) ? COLORS[c as Kind] : COLORS.G })
280 }
281 rows.push(row)
282 }
283
284 return rows
285}
286
287// Block-letter title, five rows of five-wide glyphs.
288const FONT: Record<string, readonly string[]> = {
289 A: [' ███ ', '█ █', '█████', '█ █', '█ █'],
290 B: ['████ ', '█ █', '████ ', '█ █', '████ '],
291 C: [' ████', '█ ', '█ ', '█ ', ' ████'],
292 E: ['█████', '█ ', '████ ', '█ ', '█████'],
293 K: ['█ █', '█ █ ', '███ ', '█ █ ', '█ █'],
294 L: ['█ ', '█ ', '█ ', '█ ', '█████'],
295 O: [' ███ ', '█ █', '█ █', '█ █', ' ███ '],
296 T: ['█████', ' █ ', ' █ ', ' █ ', ' █ '],
297}
298export const bigWord = (word: string): string[] => [0, 1, 2, 3, 4].map(r => [...word].map(c => FONT[c]![r]!).join(' '))
299hooks/net.ts 248 lines1import type { HttpInit, HttpResponse } from 'claude-code'
2
3import { CHUNK, MAX_STEPS } from './log'
4import type { LogMsg } from './log'
5import type { BotLevel, BotRow, ClientMsg, Incoming, Leaderboard, Online, Opponent } from '../types'
6
7export type Fetch = (url: string, init?: HttpInit) => Promise<HttpResponse>
8
9export type GiveUp = (signal: AbortSignal) => Promise<void>
10
11// The returned fetch rejects once giveUp resolves, and cancels giveUp once the request settles.
12export const withGiveUp =
13 (fetch: Fetch, giveUp: GiveUp): Fetch =>
14 async (url, init) => {
15 const stop = new AbortController()
16 const timedOut = giveUp(stop.signal).then(
17 () => Promise.reject(new Error('timed out')),
18 () => new Promise<never>(() => undefined),
19 )
20 try {
21 return await Promise.race([fetch(url, init), timedOut])
22 } finally {
23 stop.abort()
24 }
25 }
26
27export type Reply<T> = { ok: true; data: T } | { ok: false; status: number; error: string }
28
29// The server's cap on garbage in one log, so also the most a room can deliver.
30export const MAX_INCOMING = 10_000
31
32export const PROTOCOL_VERSION = 3
33const KEY = /^[A-Za-z0-9_-]{1,64}$/
34// 200,000 input pairs and 10,000 garbage pairs, the server's caps.
35const MAX_STREAM = 420_000
36
37export function serverUrlOk(u: string): boolean {
38 if (u.length > 200) return false
39 try {
40 const url = new URL(u)
41 return url.protocol === 'https:' || (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1'))
42 } catch {
43 return false
44 }
45}
46
47export function buildRequest(
48 base: string,
49 method: 'GET' | 'POST' | 'DELETE',
50 path: string,
51 token: string | null,
52 body?: unknown,
53): { url: string; init: HttpInit } {
54 const headers: Record<string, string> = {
55 ...(token === null ? {} : { Authorization: `Bearer ${token}` }),
56 Accept: 'application/json',
57 'X-Protocol-Version': String(PROTOCOL_VERSION),
58 }
59 if (body !== undefined) headers['Content-Type'] = 'application/json'
60
61 return {
62 url: base.replace(/\/+$/, '') + path,
63 init: { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }) },
64 }
65}
66
67export async function call(
68 fetch: Fetch,
69 base: string,
70 method: 'GET' | 'POST' | 'DELETE',
71 path: string,
72 token: string | null,
73 body?: unknown,
74 giveUp: GiveUp = () => new Promise(() => undefined),
75): Promise<Reply<unknown>> {
76 const { url, init } = buildRequest(base, method, path, token, body)
77 try {
78 const res = await withGiveUp(fetch, giveUp)(url, init)
79 let data: unknown = null
80 try {
81 data = JSON.parse(res.text)
82 } catch {
83 // a non-JSON body (a proxy error page) is handled below by status
84 }
85 if (res.ok) return { ok: true, data }
86 const error = (data as { error?: unknown } | null)?.error
87
88 return { ok: false, status: res.status, error: typeof error === 'string' ? cleanText(error) : `HTTP ${res.status}` }
89 } catch {
90 return { ok: false, status: 0, error: 'unreachable' }
91 }
92}
93
94const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
95const LOGIN = /^[A-Za-z0-9-]{1,39}$/
96const SNAPSHOT = /^[.IOTSZJLG]{0,400}$/
97export const login = (v: unknown): string | null => (typeof v === 'string' && LOGIN.test(v) ? v : null)
98const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
99const count = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
100export const cleanText = (s: string) => s.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, '').slice(0, 120)
101
102const isLevel = (v: unknown): v is BotLevel => v === 'easy' || v === 'medium' || v === 'hard'
103
104const botRows = (v: unknown): BotRow[] =>
105 (Array.isArray(v) ? v : [])
106 .filter(isRecord)
107 .filter(r => login(r.login) && count(r.ms) && count(r.at))
108 .map(r => ({ login: r.login as string, ms: r.ms as number, at: r.at as number }))
109 .slice(0, 5)
110
111// A server older than 1.1.0 sends no `bot`; its boards then read as empty.
112export function parseLeaderboard(data: unknown): Leaderboard | null {
113 if (!isRecord(data) || !Array.isArray(data.marathon) || !Array.isArray(data.wins)) return null
114 const marathon = data.marathon
115 .filter(isRecord)
116 .filter(r => login(r.login) && count(r.score) && count(r.lines) && count(r.level) && count(r.at))
117 .map(r => ({ login: r.login as string, score: r.score as number, lines: r.lines as number, level: r.level as number, at: r.at as number }))
118 const wins = data.wins
119 .filter(isRecord)
120 .filter(r => login(r.login) && count(r.wins))
121 .map(r => ({ login: r.login as string, wins: r.wins as number }))
122 const bot = isRecord(data.bot) ? data.bot : {}
123
124 return { marathon: marathon.slice(0, 5), wins: wins.slice(0, 5), bot: { easy: botRows(bot.easy), medium: botRows(bot.medium), hard: botRows(bot.hard) } }
125}
126
127export type QueueReply = (
128 | { status: 'waiting' }
129 | { status: 'matched'; roomId: string; seed: number; opponent: string }
130) & { online: Online | null }
131
132const MAX_ONLINE = 100_000
133
134// Servers before 1.3.0 send no counts.
135function parseOnline(v: unknown): Online | null {
136 if (!isRecord(v) || !count(v.playing) || !count(v.looking)) return null
137
138 return { playing: Math.min(v.playing, MAX_ONLINE), looking: Math.min(v.looking, MAX_ONLINE) }
139}
140
141export function parseQueue(data: unknown): QueueReply | null {
142 if (!isRecord(data)) return null
143 const online = parseOnline(data.online)
144 if (data.status === 'waiting') return { status: 'waiting', online }
145 if (data.status === 'matched' && typeof data.roomId === 'string' && KEY.test(data.roomId) && count(data.seed)) {
146 const opp = isRecord(data.opponent) ? login(data.opponent.login) : null
147 if (opp) return { status: 'matched', roomId: data.roomId, seed: data.seed, opponent: opp, online }
148 }
149
150 return null
151}
152
153export function parseMarathonStart(data: unknown): { gameId: string; seed: number } | null {
154 if (!isRecord(data) || typeof data.gameId !== 'string' || !KEY.test(data.gameId)) return null
155 const { seed } = data
156
157 return count(seed) && seed < 2 ** 32 ? { gameId: data.gameId, seed } : null
158}
159
160export type SyncReply = { opponent: Opponent | null; incoming: Incoming[]; ended: boolean; winner: string | null }
161
162export function parseSync(data: unknown): SyncReply | null {
163 if (!isRecord(data)) return null
164 const o = isRecord(data.opponent) ? data.opponent : null
165 const snapshot = typeof o?.snapshot === 'string' ? o.snapshot : ''
166 const name = login(o?.login)
167 const opp = o && name && SNAPSHOT.test(snapshot) ? { login: name, snapshot, isOver: o.isOver === true } : null
168 const incoming = Array.isArray(data.incoming)
169 ? data.incoming
170 .slice(0, MAX_INCOMING)
171 .filter(isRecord)
172 .filter(a => count(a.id) && (a.id as number) >= 1 && Number.isInteger(a.lines) && (a.lines as number) >= 1 && (a.lines as number) <= 40)
173 .map(a => ({ id: a.id as number, lines: a.lines as number }))
174 : []
175 const ended = isRecord(data.result)
176 const winner = ended ? login((data.result as Record<string, unknown>).winner) : null
177
178 return { opponent: opp, incoming, ended, winner }
179}
180
181// The server may redeliver a batch when a poll is retried; the id is the identity, so keep the first copy of each.
182// Stops at MAX_INCOMING: past that the log could not hold them anyway.
183export function mergeIncoming(have: readonly Incoming[], got: readonly Incoming[]): Incoming[] {
184 const seen = new Set(have.map(a => a.id))
185 const fresh = got.filter(a => !seen.has(a.id) && seen.add(a.id))
186
187 return [...have, ...fresh.slice(0, MAX_INCOMING - have.length)]
188}
189
190// Attacks go out in a payload with a seq. A failed send is retried with the same seq and the
191// same attacks, which the server treats as idempotent; attacks made meanwhile wait in `queued`.
192export type Outbox = {
193 seq: number
194 inflight: { seq: number; attacks: number[] } | null
195 queued: number[]
196}
197
198export const emptyOutbox = (): Outbox => ({ seq: 0, inflight: null, queued: [] })
199
200export function nextPayload(box: Outbox): { seq: number; attacks: number[] } {
201 if (box.inflight) return box.inflight
202 const attacks = box.queued.splice(0, 20)
203 box.seq += 1
204 box.inflight = { seq: box.seq, attacks }
205
206 return box.inflight
207}
208
209export function queueAttacks(box: Outbox, lines: readonly number[]): void {
210 for (const n of lines) if (Number.isInteger(n) && n >= 1) box.queued.push(Math.min(40, n))
211}
212
213// A Client posts plain data the hooks side must treat as input, not fact.
214export function parseClientMsg(data: unknown): ClientMsg | null {
215 if (!isRecord(data)) return null
216 if (data.type === 'menu') {
217 const c = data.choice
218 if (c === 'battle') return typeof data.server === 'string' && serverUrlOk(data.server) ? { type: 'menu', choice: c, server: data.server } : null
219 if (c === 'bot') return isLevel(data.level) && count(data.nonce) ? { type: 'menu', choice: c, level: data.level, nonce: data.nonce } : null
220 if (c === 'marathon') return count(data.nonce) ? { type: 'menu', choice: c, nonce: data.nonce } : null
221
222 return c === 'leaderboard' || c === 'back' ? { type: 'menu', choice: c } : null
223 }
224 if (data.type === 'log') return parseLogMsg(data)
225 if (data.type === 'sync') {
226 const { snapshot, isOver, attacks } = data
227 if (typeof snapshot !== 'string' || snapshot.length > 400 || typeof isOver !== 'boolean') return null
228 if (!Array.isArray(attacks) || !attacks.every(a => Number.isInteger(a))) return null
229
230 return { type: 'sync', seq: num(data.seq), attacks: attacks as number[], snapshot, isOver }
231 }
232
233 return null
234}
235
236function parseLogMsg(d: Record<string, unknown>): LogMsg | null {
237 const { kind, key, steps, inputsLen, total, at, values } = d
238 if (kind !== 'marathon' && kind !== 'battle' && kind !== 'bot') return null
239 if (typeof key !== 'string' || !KEY.test(key)) return null
240 if (!count(steps) || steps < 1 || steps > MAX_STEPS) return null
241 if (!count(total) || total > MAX_STREAM || total % 2 !== 0) return null
242 if (!count(inputsLen) || inputsLen % 2 !== 0 || inputsLen > total) return null
243 if (!count(at) || at > total) return null
244 if (!Array.isArray(values) || values.length > CHUNK || at + values.length > total || !values.every(count)) return null
245
246 return { type: 'log', kind, key, steps, inputsLen, total, at, values: values as number[] }
247}
248hooks/log.ts 122 lines1import type { ClientMsg } from '../types'
2import type { Input } from './engine'
3
4export const STEP_MS = 16
5export const MAX_STEPS = 450_000
6const MAX_INPUTS = 200_000
7const MAX_GARBAGE = 10_000
8// Steps run in one tick at most; any further debt carries to later ticks.
9const MAX_CATCH_UP = 15
10export const CHUNK = 10_000
11
12// The server's list (claude-games-server src/protocol.ts). A logged code is an index here.
13export const INPUT_NAMES = [
14 'left', 'right', 'softDropOn', 'softDropOff', 'softDropStep', 'hardDrop', 'rotateCW', 'rotateCCW', 'rotate180', 'hold',
15] as const satisfies readonly Input[]
16
17export type GameLog = { steps: number; inputs: number[]; garbage?: number[] }
18
19// Kept in wire form as the game runs: flat [stepDelta, value] pairs, each delta from the previous pair.
20export type Recorder = { steps: number; inputs: number[]; garbage: number[]; lastInput: number; lastGarbage: number; isFull: boolean }
21
22export const newRecorder = (): Recorder => ({ steps: 0, inputs: [], garbage: [], lastInput: 0, lastGarbage: 0, isFull: false })
23
24// Called before the step it lands on runs, as the server applies it.
25export function recordGarbage(rec: Recorder, id: number): void {
26 if (rec.isFull) return
27 if (rec.garbage.length >= MAX_GARBAGE * 2) {
28 rec.isFull = true
29 return
30 }
31 rec.garbage.push(rec.steps - rec.lastGarbage, id)
32 rec.lastGarbage = rec.steps
33}
34
35// A full recorder stops counting, so what it holds is still a log the server can replay.
36export function recordStep(rec: Recorder, inputs: readonly Input[]): void {
37 if (rec.isFull) return
38 if (rec.inputs.length + inputs.length * 2 > MAX_INPUTS * 2) {
39 rec.isFull = true
40 return
41 }
42 for (const i of inputs) {
43 rec.inputs.push(rec.steps - rec.lastInput, INPUT_NAMES.indexOf(i))
44 rec.lastInput = rec.steps
45 }
46 rec.steps++
47 if (rec.steps >= MAX_STEPS) rec.isFull = true
48}
49
50// Steps due = base + max(ticks fired, real time / STEP_MS) - steps run. Ticks keep the test kit's frame
51// clock exact, real time catches up after a host stalls; neither runs ahead of real time on a real host.
52export type Pace = { base: number; ran: number; ticks: number; startAt: number }
53
54export const newPace = (at: number, ran = 0): Pace => ({ base: ran, ran, ticks: 0, startAt: at })
55
56export function stepsDue(p: Pace, at: number): number {
57 p.ticks++
58 const target = p.base + Math.max(p.ticks, Math.floor((at - p.startAt) / STEP_MS))
59 const n = Math.max(0, Math.min(MAX_CATCH_UP, target - p.ran))
60 p.ran += n
61
62 return n
63}
64
65export type Upload = { kind: 'marathon' | 'battle' | 'bot'; key: string; steps: number; inputsLen: number; stream: number[] }
66
67export function toUpload(kind: Upload['kind'], key: string, rec: Recorder): Upload | null {
68 if (rec.steps < 1) return null
69
70 return { kind, key, steps: rec.steps, inputsLen: rec.inputs.length, stream: [...rec.inputs, ...rec.garbage] }
71}
72
73const RESEND_MS = 250
74// About 10 s of re-sends with no progress: the hooks will never take this upload.
75const MAX_STALLS = 40
76
77export type Sender = { lastAck: number; waitMs: number; stalls: number }
78
79export const newSender = (): Sender => ({ lastAck: -1, waitMs: 0, stalls: 0 })
80
81// Called once per tick with the position the hooks acknowledged for the upload being sent.
82export function nextSend(s: Sender, have: number): 'wait' | 'send' | 'drop' {
83 s.waitMs += STEP_MS
84 if (have === s.lastAck && s.waitMs < RESEND_MS) return 'wait'
85 s.stalls = have === s.lastAck ? s.stalls + 1 : 0
86 if (s.stalls >= MAX_STALLS) return 'drop'
87 s.lastAck = have
88 s.waitMs = 0
89
90 return 'send'
91}
92
93export type LogMsg = Extract<ClientMsg, { type: 'log' }>
94
95export const chunkAt = (u: Upload, at: number): LogMsg => ({
96 type: 'log', kind: u.kind, key: u.key, steps: u.steps, inputsLen: u.inputsLen, total: u.stream.length, at, values: u.stream.slice(at, at + CHUNK),
97})
98
99export type Assembly = { kind: Upload['kind']; key: string; steps: number; inputsLen: number; total: number; values: number[] }
100
101const whole = (v: number) => Number.isSafeInteger(v) && v >= 0
102
103// Appends a chunk only at the position already held; anything else is answered with that position
104// so the Client re-sends from there.
105export function takeChunk(cur: Assembly | null, m: LogMsg): { asm: Assembly | null; have: number; log: GameLog | null } {
106 if (!m.values.every(whole)) return { asm: cur, have: cur?.key === m.key ? cur.values.length : 0, log: null }
107 let asm = cur
108 if (!asm || asm.key !== m.key || asm.kind !== m.kind) {
109 if (m.at !== 0) return { asm: cur, have: 0, log: null }
110 asm = { kind: m.kind, key: m.key, steps: m.steps, inputsLen: m.inputsLen, total: m.total, values: [] }
111 }
112 if (m.at === asm.values.length && asm.values.length + m.values.length <= asm.total) {
113 asm = { ...asm, values: [...asm.values, ...m.values] }
114 }
115 const have = asm.values.length
116 if (have < asm.total) return { asm, have, log: null }
117 const garbage = asm.values.slice(asm.inputsLen)
118 const log: GameLog = { steps: asm.steps, inputs: asm.values.slice(0, asm.inputsLen), ...(garbage.length > 0 ? { garbage } : {}) }
119
120 return { asm, have, log }
121}
122hooks/game.tsx 1004 lines1import type { ClientModule, ClientSurface, RenderChildren } from 'claude-code'
2
3import type { BotLevel, ClientMsg, GameView } from '../types'
4import { CLEAR_MS, COMPACT, FIELD_W, GARBAGE_MS, LABEL_MS, LEVEL_MS, OVER_FINAL_MS, TIERS, TRAIL_MS, bigWord, blankRows, emptyFx, fieldRows, formatMs, overStep, pickTier, previewRows, runs, snapshotRows, vsOverlay } from './draw'
5import type { Ch, Fx, Label, Overlay, Size } from './draw'
6import { newGame, receiveGarbage, snapshot, step } from './engine'
7import type { Game, GameEvent, Input, Mode } from './engine'
8import { STEP_MS, chunkAt, newPace, newRecorder, newSender, nextSend, recordGarbage, recordStep, stepsDue, toUpload } from './log'
9import type { LogMsg, Pace, Recorder, Sender, Upload } from './log'
10import { newMatch, stepMatch } from './match.ts'
11import type { Match } from './match.ts'
12import { serverUrlOk } from './net'
13
14// Rows per down press; tuned by feel (1 was too slow). A step stops at the floor, so more never locks.
15const SOFT_DROP_ROWS = 2
16const SYNC_MS = 200
17const START_WAIT_MS = 3_000
18
19type Screen = 'menu' | 'servers' | 'lobby' | 'play' | 'leaderboard' | 'starting' | 'levels'
20
21type Vs = { level: BotLevel; match: Match; gameId: string | null; snap: string }
22
23// Everything mutable lives here, so the 16 ms tick changes it in place and asks for a redraw only when
24// something visible moved. `rev` is what setState carries to make the engine call the module again.
25type Live = {
26 props: GameView
27 rev: number
28 screen: Screen
29 menu: number
30 mode: Mode
31 game: Game | null
32 inputs: Input[]
33 t: number
34 pace: Pace
35 rec: Recorder
36 gameId: string | null
37 nonce: number
38 waitMs: number
39 waitAt: number
40 uploads: Upload[]
41 sender: Sender
42 hasSentLog: boolean
43 isPaused: boolean
44 fx: Fx
45 overMs: number
46 // How many of the room's incoming attacks were taken; the list only grows.
47 applied: number
48 outbox: ClientMsg[]
49 attacks: number[]
50 syncMs: number
51 seq: number
52 roomId: string | null
53 hasPostedOver: boolean
54 result: 'win' | 'loss' | null
55 isDirty: boolean
56 hasKeyed: boolean
57 pick: number
58 // The address being typed on the server screen, or null while choosing from the list.
59 typing: string | null
60 typeError: string | null
61 vs: Vs | null
62 // Set while the Starting screen waits on a Vs Bot game rather than a Marathon one.
63 startingBot: BotLevel | null
64 // The highest props.vsbot nonce already adopted or closed.
65 botSeen: number
66 lbPage: 0 | 1
67}
68
69const MENU = [
70 { label: 'Marathon', hint: 'solo, endless levels' },
71 { label: 'Vs Bot', hint: 'solo, against a bot' },
72 { label: 'Battle', hint: 'online 1v1' },
73 { label: 'Leaderboard', hint: 'top 5' },
74] as const
75
76const BOT_LEVELS = [
77 { id: 'easy', name: 'Merge Conflict (easy)' },
78 { id: 'medium', name: 'Hotfix in Prod (medium)' },
79 { id: 'hard', name: 'Deploy on Friday (hard)' },
80] as const satisfies readonly { id: BotLevel; name: string }[]
81
82const emptyProps = (): GameView => ({
83 me: null,
84 leaderboard: null,
85 notice: null,
86 battle: { status: 'idle', roomId: null, seed: 0, opponent: null, incoming: [], result: null, online: null },
87 servers: { home: '', last: null, active: '' },
88 marathon: null,
89 vsbot: null,
90 uploaded: null,
91})
92
93const newLive = (props: GameView): Live => ({
94 props, rev: 0, screen: 'menu', menu: 0, mode: 'marathon', game: null, inputs: [], t: 0,
95 pace: newPace(0), rec: newRecorder(), gameId: null, nonce: 0, waitMs: 0, waitAt: 0, uploads: [], sender: newSender(), hasSentLog: false,
96 isPaused: false, fx: emptyFx(), overMs: 0, applied: 0, outbox: [], attacks: [], syncMs: 0, seq: 0, roomId: null,
97 hasPostedOver: false, result: null, isDirty: true, hasKeyed: false, pick: 0, typing: null, typeError: null,
98 vs: null, startingBot: null, botSeen: 0, lbPage: 0,
99})
100
101const CLEAR_NAMES: Record<string, string> = {
102 single: 'SINGLE', double: 'DOUBLE', triple: 'TRIPLE', quad: 'QUAD',
103 tspinMini: 'T-SPIN MINI', tspinMiniSingle: 'T-SPIN MINI SINGLE', tspinMiniDouble: 'T-SPIN MINI DOUBLE',
104 tspin: 'T-SPIN', tspinSingle: 'T-SPIN SINGLE', tspinDouble: 'T-SPIN DOUBLE', tspinTriple: 'T-SPIN TRIPLE',
105}
106
107function pushLabel(fx: Fx, text: string, color: string) {
108 fx.labels = [...fx.labels, { text, color, age: 0 }].slice(-3)
109}
110
111function applyEvents(live: Live, before: Game, events: readonly GameEvent[]) {
112 const { fx } = live
113 for (const ev of events) {
114 if (ev.type === 'lineClear') {
115 fx.clear = { rows: ev.rows, age: 0 }
116 if (ev.perfectClear) pushLabel(fx, 'PERFECT CLEAR', '#ffffff')
117 pushLabel(fx, CLEAR_NAMES[ev.kind] ?? String(ev.kind).toUpperCase(), ev.kind === 'quad' ? '#00e5ff' : ev.kind.startsWith('tspin') ? '#b44cff' : '#ffd400')
118 if (ev.b2b) pushLabel(fx, 'BACK-TO-BACK', '#ff9a1f')
119 if (ev.combo >= 1) pushLabel(fx, `COMBO x${ev.combo}`, '#38d64a')
120 if (live.mode === 'battle' && !live.vs && ev.attack > 0) live.attacks.push(ev.attack)
121 } else if (ev.type === 'hardDrop') {
122 const kind = before.active?.kind
123 if (kind && Array.isArray(ev.columns)) fx.trail = { cols: ev.columns, from: ev.from, to: ev.to, kind, age: 0 }
124 } else if (ev.type === 'garbageIn') {
125 fx.garbage = { lines: ev.lines, age: 0 }
126 } else if (ev.type === 'levelUp') {
127 fx.levelUp = 0
128 pushLabel(fx, `LEVEL ${ev.level}`, '#ffd400')
129 }
130 }
131}
132
133function ageFx(fx: Fx, dt: number): boolean {
134 let isLive = false
135 const grow = <T extends { age: number }>(x: T | null, limit: number): T | null => {
136 if (!x) return null
137 const next = { ...x, age: x.age + dt }
138 isLive = true
139
140 return next.age >= limit ? null : next
141 }
142 fx.clear = grow(fx.clear, CLEAR_MS)
143 fx.trail = grow(fx.trail, TRAIL_MS)
144 fx.garbage = grow(fx.garbage, GARBAGE_MS)
145 if (fx.levelUp !== null) {
146 isLive = true
147 fx.levelUp += dt
148 if (fx.levelUp >= LEVEL_MS) fx.levelUp = null
149 }
150 if (fx.labels.length > 0) {
151 isLive = true
152 fx.labels = fx.labels.map(l => ({ ...l, age: l.age + dt })).filter(l => l.age < LABEL_MS)
153 }
154
155 return isLive
156}
157
158function startGame(live: Live, mode: Mode, seed: number) {
159 live.screen = 'play'
160 live.mode = mode
161 live.vs = null
162 live.game = newGame(mode, seed)
163 live.inputs = []
164 live.isPaused = false
165 live.fx = emptyFx()
166 live.overMs = 0
167 live.attacks = []
168 live.syncMs = 0
169 live.hasPostedOver = false
170 live.result = null
171 live.pace = newPace(performance.now())
172 live.rec = newRecorder()
173 live.hasSentLog = false
174 live.isDirty = true
175}
176
177const randomSeed = () => Math.floor(Math.random() * 0x100000000)
178
179function askMarathon(live: Live) {
180 live.screen = 'starting'
181 live.startingBot = null
182 live.nonce++
183 live.waitMs = 0
184 live.waitAt = performance.now()
185 live.outbox.push({ type: 'menu', choice: 'marathon', nonce: live.nonce })
186 live.isDirty = true
187}
188
189function askBot(live: Live, level: BotLevel) {
190 live.screen = 'starting'
191 live.startingBot = level
192 live.nonce++
193 live.waitMs = 0
194 live.waitAt = performance.now()
195 live.outbox.push({ type: 'menu', choice: 'bot', level, nonce: live.nonce })
196 live.isDirty = true
197}
198
199function startVs(live: Live, level: BotLevel, gameId: string | null, seed: number) {
200 startGame(live, 'battle', seed)
201 live.startingBot = null
202 live.roomId = null
203 live.gameId = null
204 const match = newMatch(seed, level)
205 live.vs = { level, match, gameId, snap: '' }
206 live.game = match.me
207}
208
209// Closes a ranked game this Client will not play: the server counts it as a loss.
210function forfeit(live: Live, gameId: string) {
211 const rec = newRecorder()
212 recordStep(rec, [])
213 const u = toUpload('bot', gameId, rec)
214 if (u) live.uploads.push(u)
215}
216
217function queueUpload(live: Live, kind: Upload['kind']) {
218 const key = kind === 'marathon' ? live.gameId : kind === 'bot' ? (live.vs?.gameId ?? null) : live.roomId
219 if (key === null) return
220 const u = toUpload(kind, key, live.rec)
221 if (u) live.uploads.push(u)
222}
223
224// One chunk at a time: the next goes when the hooks' acknowledged position moves, or again after a
225// pause if it did not. Done once the hooks hold the whole stream for its key, or dropped if they never move.
226function nextChunk(live: Live): LogMsg | null {
227 const u = live.uploads[0]
228 if (!u) return null
229 const ack = live.props.uploaded
230 const isAcked = ack !== null && ack.key === u.key
231 const have = isAcked ? ack.have : 0
232 const next = isAcked && have >= u.stream.length ? 'drop' : nextSend(live.sender, have)
233 if (next === 'drop') {
234 live.uploads.shift()
235 live.sender = newSender()
236 return nextChunk(live)
237 }
238 return next === 'send' ? chunkAt(u, have) : null
239}
240
241function leave(live: Live) {
242 if (live.mode === 'marathon' && live.game && !live.game.isOver) queueUpload(live, 'marathon')
243 if (live.vs && !live.result) queueUpload(live, 'bot')
244 live.vs = null
245 live.screen = 'menu'
246 live.game = null
247 live.result = null
248 live.outbox.push({ type: 'menu', choice: 'back' })
249 live.isDirty = true
250}
251
252type ServerChoice = { label: string; url: string | null }
253
254function serverChoices(live: Live): ServerChoice[] {
255 const { home, last } = live.props.servers
256 const list: ServerChoice[] = [
257 { label: 'Official server', url: home },
258 { label: 'Enter a server address...', url: null },
259 ]
260 if (last && last !== home) list.push({ label: `${hostOf(last)} (last used)`, url: last })
261
262 return list
263}
264
265const hostOf = (url: string) => {
266 try {
267 return new URL(url).host
268 } catch {
269 return url
270 }
271}
272
273function playOn(live: Live, server: string) {
274 live.screen = 'lobby'
275 live.typing = null
276 live.outbox.push({ type: 'menu', choice: 'battle', server: server.replace(/\/+$/, '') })
277}
278
279function onServerKey(live: Live, key: string) {
280 if (live.typing !== null) {
281 if (key === 'return') {
282 const url = live.typing.trim()
283 if (serverUrlOk(url)) playOn(live, url)
284 else live.typeError = 'The address must start with https://'
285 } else if (key === 'backspace' || key === 'delete') live.typing = live.typing.slice(0, -1)
286 else if (key === 'up' || key === 'down') live.typing = null
287 else if (key.length === 1 && key >= ' ' && live.typing.length < 200) live.typing += key
288 if (key !== 'return') live.typeError = null
289
290 return
291 }
292 const choices = serverChoices(live)
293 const k = key.length === 1 && key !== ' ' ? key.toLowerCase() : key
294 if (k === 'up' || k === 'k') live.pick = (live.pick + choices.length - 1) % choices.length
295 else if (k === 'down' || k === 'j') live.pick = (live.pick + 1) % choices.length
296 else if (k === 'q') live.screen = 'menu'
297 else if (k === 'return' || k === ' ') {
298 const c = choices[live.pick]!
299 if (c.url === null) live.typing = ''
300 else playOn(live, c.url)
301 }
302}
303
304function onKey(live: Live, key: string) {
305 live.hasKeyed = true
306 const k = key.length === 1 && key !== ' ' ? key.toLowerCase() : key
307 if (live.screen === 'menu') {
308 if (k === 'up' || k === 'k') live.menu = (live.menu + MENU.length - 1) % MENU.length
309 else if (k === 'down' || k === 'j') live.menu = (live.menu + 1) % MENU.length
310 else if (k === 'return' || k === ' ') {
311 if (live.menu === 0) askMarathon(live)
312 else if (live.menu === 1) {
313 live.screen = 'levels'
314 live.pick = 0
315 } else if (live.menu === 2) {
316 live.screen = 'servers'
317 live.pick = 0
318 live.typing = null
319 live.typeError = null
320 } else {
321 live.screen = 'leaderboard'
322 live.lbPage = 0
323 live.outbox.push({ type: 'menu', choice: 'leaderboard' })
324 }
325 }
326 live.isDirty = true
327
328 return
329 }
330 if (live.screen === 'servers') {
331 onServerKey(live, key)
332 live.isDirty = true
333
334 return
335 }
336 if (live.screen === 'levels') {
337 if (k === 'up' || k === 'k') live.pick = (live.pick + BOT_LEVELS.length - 1) % BOT_LEVELS.length
338 else if (k === 'down' || k === 'j') live.pick = (live.pick + 1) % BOT_LEVELS.length
339 else if (k === 'q') live.screen = 'menu'
340 else if (k === 'return' || k === ' ') askBot(live, BOT_LEVELS[live.pick]!.id)
341 live.isDirty = true
342
343 return
344 }
345 if (live.screen === 'lobby' || live.screen === 'leaderboard' || live.screen === 'starting') {
346 if (k === 'q' || (k === 'return' && live.screen !== 'starting')) leave(live)
347 else if (k === 'r' && live.screen === 'leaderboard') live.outbox.push({ type: 'menu', choice: 'leaderboard' })
348 else if ((k === 'left' || k === 'right') && live.screen === 'leaderboard') live.lbPage = live.lbPage === 0 ? 1 : 0
349 live.isDirty = true
350
351 return
352 }
353 if (k === 'q') return leave(live)
354 const game = live.game
355 if (!game) return
356 if (game.isOver || live.result) {
357 if (k === 'r' && live.mode === 'marathon') askMarathon(live)
358
359 return
360 }
361 if (k === 'p' && live.mode === 'marathon') {
362 live.isPaused = !live.isPaused
363 live.pace = newPace(performance.now(), live.pace.ran)
364 live.isDirty = true
365
366 return
367 }
368 if (live.isPaused) return
369 if (k === 'left') live.inputs.push('left')
370 else if (k === 'right') live.inputs.push('right')
371 // SOFT_DROP_ROWS per press: a terminal sends no key-up, so a held soft drop could not be told apart
372 // from a tap and would slam the piece down. Holding the key rides the keyboard's own repeat.
373 else if (k === 'down') for (let i = 0; i < SOFT_DROP_ROWS; i++) live.inputs.push('softDropStep')
374 else if (k === 'up' || k === 'x') live.inputs.push('rotateCW')
375 else if (k === 'z') live.inputs.push('rotateCCW')
376 else if (k === 'a') live.inputs.push('rotate180')
377 else if (k === ' ' || k === 'space') live.inputs.push('hardDrop')
378 else if (k === 'c') live.inputs.push('hold')
379}
380
381function tick(live: Live) {
382 const p = live.props
383 if (live.screen === 'lobby' && p.battle.status === 'matched' && p.battle.roomId !== null && p.battle.roomId !== live.roomId) {
384 live.roomId = p.battle.roomId
385 live.applied = 0
386 live.gameId = null
387 startGame(live, 'battle', p.battle.seed)
388 }
389 if (live.screen === 'starting') {
390 live.waitMs += STEP_MS
391 const isLate = Math.max(live.waitMs, performance.now() - live.waitAt) >= START_WAIT_MS
392 const level = live.startingBot
393 if (level !== null) {
394 const b = p.vsbot
395 if (b && b.nonce === live.nonce) {
396 live.botSeen = b.nonce
397 startVs(live, level, b.gameId, b.gameId === null ? randomSeed() : b.seed)
398 } else if (isLate) startVs(live, level, null, randomSeed())
399 } else {
400 const m = p.marathon
401 if (m && m.nonce === live.nonce) {
402 live.gameId = m.gameId
403 startGame(live, 'marathon', m.gameId === null ? randomSeed() : m.seed)
404 } else if (isLate) {
405 live.gameId = null
406 startGame(live, 'marathon', randomSeed())
407 }
408 }
409 }
410 const vb = p.vsbot
411 if (vb && vb.nonce > live.botSeen && !(live.screen === 'starting' && live.startingBot !== null && vb.nonce === live.nonce)) {
412 live.botSeen = vb.nonce
413 if (vb.gameId !== null) forfeit(live, vb.gameId)
414 }
415 if (live.screen === 'play' && live.mode === 'battle' && !live.vs && live.roomId !== null && p.battle.status === 'idle') {
416 // The server dropped the match under us: back to the lobby, where the notice is shown.
417 live.screen = 'lobby'
418 live.game = null
419 live.result = null
420 live.roomId = null
421 live.isDirty = true
422 }
423 const n = live.screen === 'play' && !live.isPaused ? stepsDue(live.pace, performance.now()) : 1
424 const dt = n * STEP_MS
425 live.t += dt
426 if (live.screen === 'starting' && live.t % 320 < STEP_MS) live.isDirty = true
427 let dirty = live.isDirty
428 const game = live.game
429 if (live.screen === 'play' && game) {
430 if (live.mode === 'battle' && !live.vs && live.roomId === p.battle.roomId) {
431 let g = game
432 if (!g.isOver && !live.result) {
433 const { incoming } = p.battle
434 for (; live.applied < incoming.length; live.applied++) {
435 const inc = incoming[live.applied]!
436 recordGarbage(live.rec, inc.id)
437 g = receiveGarbage(g, inc.lines)
438 dirty = true
439 }
440 }
441 live.game = g
442 if (p.battle.result && !live.result) {
443 live.result = p.battle.result
444 dirty = true
445 }
446 if (live.result && !live.hasSentLog) {
447 live.hasSentLog = true
448 queueUpload(live, 'battle')
449 }
450 }
451 if (n > 0 && !live.isPaused && !live.game!.isOver && !live.result) {
452 let inputs = live.inputs
453 live.inputs = []
454 let g = live.game!
455 const a = g.active
456 let moved = inputs.length > 0
457 // A full recorder freezes the board: nothing past it could be verified.
458 for (let i = 0; i < n && !g.isOver && !live.rec.isFull; i++) {
459 const counted = live.rec.steps
460 recordStep(live.rec, inputs)
461 if (live.rec.steps === counted) break
462 if (live.vs) {
463 const r = stepMatch(live.vs.match, inputs)
464 applyEvents(live, g, r.me)
465 if (r.me.length > 0) moved = true
466 g = live.vs.match.me
467 inputs = []
468 if (live.vs.match.winner) break
469 continue
470 }
471 const r = step(g, inputs, STEP_MS)
472 applyEvents(live, g, r.events)
473 if (r.events.length > 0) moved = true
474 g = r.game
475 inputs = []
476 }
477 live.game = g
478 const b = g.active
479 if (moved || a?.x !== b?.x || a?.y !== b?.y || a?.rotation !== b?.rotation || a?.kind !== b?.kind) dirty = true
480 }
481 if (live.vs) {
482 const snap = snapshot(live.vs.match.bot)
483 if (snap !== live.vs.snap) {
484 live.vs.snap = snap
485 dirty = true
486 }
487 const w = live.vs.match.winner
488 if (w && !live.result) {
489 live.result = w === 'me' ? 'win' : 'loss'
490 queueUpload(live, 'bot')
491 dirty = true
492 }
493 }
494 const now = live.game!
495 if (now.isOver || live.result) {
496 const next = overStep(live.overMs, dt)
497 if (next !== live.overMs) dirty = true
498 live.overMs = next
499 }
500 if (ageFx(live.fx, dt)) dirty = true
501
502 if (now.isOver && !live.hasPostedOver && live.mode === 'marathon') queueUpload(live, 'marathon')
503 // The top-out is posted again until a result comes: one lost post must not leave the hooks unaware.
504 if (live.mode === 'battle' && !live.vs && !live.result) {
505 live.syncMs += dt
506 if (live.syncMs >= SYNC_MS || (now.isOver && !live.hasPostedOver)) {
507 live.syncMs = 0
508 live.outbox.push({ type: 'sync', seq: ++live.seq, attacks: live.attacks.splice(0), snapshot: snapshot(now), isOver: now.isOver })
509 }
510 }
511 if (now.isOver) live.hasPostedOver = true
512 } else if (live.screen === 'lobby') {
513 if (live.t % 320 < dt) dirty = true
514 }
515
516 live.isDirty = false
517
518 return dirty
519}
520
521type Shell = { live: Live; rev: number }
522type Surf = ClientSurface<Shell>
523
524const FRAME = '#6a6a8c'
525const PANEL = '#4a4a66'
526const TITLE = '#9a9ab8'
527const BIG: Size = { cw: 4, ch: 2 }
528
529function gridEl(rows: Ch[][], surface: Surf) {
530 const { Box, Text } = surface.elements
531
532 return (
533 <Box flexDirection="column">
534 {rows.map(row => (
535 <Box flexDirection="row">
536 {runs(row).map(r => (
537 <Text color={r.fg} backgroundColor={r.bg} dimColor={r.dim} bold={r.bold} inverse={r.inverse}>
538 {r.text}
539 </Text>
540 ))}
541 </Box>
542 ))}
543 </Box>
544 )
545}
546
547// A rounded panel `width` columns wide (border included).
548function panel(surface: Surf, width: number, color: string, children: RenderChildren) {
549 const { Box } = surface.elements
550
551 return (
552 <Box flexDirection="column" width={width} borderStyle="round" borderColor={color}>
553 {children}
554 </Box>
555 )
556}
557
558function bar(surface: Surf, fraction: number, width: number) {
559 const { Box, Text } = surface.elements
560 const filled = Math.max(0, Math.min(width, Math.round(fraction * width)))
561
562 return (
563 <Box flexDirection="row">
564 <Text color="#00e5ff">{'█'.repeat(filled)}</Text>
565 <Text color="#2c2c40">{'█'.repeat(width - filled)}</Text>
566 </Box>
567 )
568}
569
570function kv(surface: Surf, width: number, label: string, value: string, color?: string) {
571 const { Box, Text } = surface.elements
572
573 return (
574 <Box width={width} justifyContent="space-between">
575 <Text dimColor>{label}</Text>
576 <Text bold color={color}>{value}</Text>
577 </Box>
578 )
579}
580
581const HELP_BIG = ['arrows move', 'up/x rotate', 'z ccw a 180', 'space hard drop', 'c hold p pause', 'q menu']
582const HELP_MEDIUM = ['arrows move up/x turn', 'z ccw a 180 spc drop', 'c hold p pause q menu']
583
584// The full prompt where it fits; a short one on compact and mini layouts, which are narrower.
585const activate = (width: number) => (width >= 28 ? 'click here to activate panel' : 'click to activate')
586
587function drawTitle(live: Live, surface: Surf, width: number) {
588 const { Box, Text } = surface.elements
589
590 return (
591 <Box width={width} justifyContent="center">
592 {live.hasKeyed ? <Text bold color="cyan">BLOCK BATTLE</Text> : <Text bold color="yellow">{activate(width)}</Text>}
593 </Box>
594 )
595}
596
597function drawOpponent(live: Live, surface: Surf, framed: boolean) {
598 const { Box, Text } = surface.elements
599 const vs = live.vs
600 const opp = vs ? { login: `${vs.level} bot`, snapshot: vs.snap, isOver: vs.match.bot.isOver } : live.props.battle.opponent
601 const name = (opp ? opp.login : 'opponent').slice(0, 10)
602 const body = (
603 <Box flexDirection="column">
604 <Box justifyContent="center">
605 <Text bold color={opp?.isOver ? 'red' : undefined}>{name}</Text>
606 </Box>
607 {gridEl(snapshotRows(opp?.snapshot ?? ''), surface)}
608 </Box>
609 )
610
611 return framed ? panel(surface, 12, PANEL, body) : <Box flexDirection="column" width={10}>{body}</Box>
612}
613
614function drawSide(live: Live, surface: Surf, tier: 'big' | 'medium', pending: number) {
615 const { Box, Text } = surface.elements
616 const game = live.game!
617 const big = tier === 'big'
618 const width = TIERS[tier].side
619 const inner = width - 2
620 const si = inner - 2
621 const hold = game.hold ? previewRows(game.hold, big ? BIG : COMPACT, inner, !game.canHold) : blankRows(big ? 4 : 2, inner)
622 const nexts = game.next.slice(0, big ? 5 : 3)
623 const title = (t: string, dim = false) => <Text bold dimColor={dim} color={TITLE}>{' ' + t}</Text>
624 const gap = () => <Text> </Text>
625 const nextBody = nexts.map((kind, i) => (
626 <Box flexDirection="column">
627 {i > 0 && gap()}
628 {gridEl(previewRows(kind, big && i === 0 ? BIG : COMPACT, inner), surface)}
629 </Box>
630 ))
631 const level = game.lines % 10
632 const status = [
633 pending > 0 ? { t: `INCOMING ${pending}`, c: 'red' } : null,
634 game.combo > 0 ? { t: `COMBO x${game.combo}`, c: 'green' } : null,
635 game.b2b ? { t: big ? 'BACK-TO-BACK' : 'B2B', c: '#ff9a1f' } : null,
636 ].filter((s): s is { t: string; c: string } => s !== null)
637 let used = 0
638 const fitted = status.filter(s => {
639 if (used + s.t.length + (used > 0 ? 1 : 0) > si) return false
640 used += s.t.length + (used > 0 ? 1 : 0)
641
642 return true
643 })
644 const barLine = (
645 <Box width={si}>
646 {bar(surface, level / 10, si - 5)}
647 <Text dimColor>{` ${level}/10`.padStart(5)}</Text>
648 </Box>
649 )
650 const stats = big ? (
651 <Box flexDirection="column">
652 <Text dimColor>SCORE</Text>
653 <Box width={si} justifyContent="flex-end">
654 <Text bold color="yellow">{String(game.score)}</Text>
655 </Box>
656 {kv(surface, si, 'LINES', String(game.lines))}
657 {kv(surface, si, 'LEVEL', String(game.level), 'cyan')}
658 {barLine}
659 <Text color="green">{game.combo > 0 ? `COMBO x${game.combo}` : ' '}</Text>
660 <Text color="#ff9a1f">{game.b2b ? 'BACK-TO-BACK' : ' '}</Text>
661 <Text color="red">{pending > 0 ? `INCOMING ${pending}` : ' '}</Text>
662 </Box>
663 ) : (
664 <Box flexDirection="column">
665 {kv(surface, si, 'SCORE', String(game.score), 'yellow')}
666 <Box width={si} gap={2}>
667 {kv(surface, 8, 'LN', String(game.lines))}
668 {kv(surface, 10, 'LEVEL', String(game.level), 'cyan')}
669 </Box>
670 {barLine}
671 <Box width={si} gap={1}>
672 {fitted.length === 0 ? <Text> </Text> : fitted.map(s => <Text color={s.c}>{s.t}</Text>)}
673 </Box>
674 </Box>
675 )
676
677 return (
678 <Box flexDirection="column" width={width}>
679 {panel(surface, width, PANEL, <Box flexDirection="column">{title('HOLD', !game.canHold)}{gridEl(hold, surface)}</Box>)}
680 {panel(surface, width, PANEL, <Box flexDirection="column">{title('NEXT')}{nextBody}</Box>)}
681 {panel(surface, width, PANEL, <Box flexDirection="column" paddingX={1}>{stats}</Box>)}
682 <Box flexDirection="column">
683 {(big ? HELP_BIG : HELP_MEDIUM).map(l => <Text dimColor>{' ' + l}</Text>)}
684 </Box>
685 </Box>
686 )
687}
688
689function drawPlay(live: Live, surface: Surf) {
690 const { Box, Text } = surface.elements
691 const game = live.game!
692 const { fx } = live
693 const isBattle = live.mode === 'battle'
694 const tier = pickTier(surface.columns, surface.rows, isBattle)
695 if (tier === 'tiny') {
696 return (
697 <Box paddingX={1}>
698 <Text color="yellow">Too small for Block Battle: widen and heighten this pane (21 x 22 at least).</Text>
699 </Box>
700 )
701 }
702 const size = tier === 'big' ? BIG : COMPACT
703 const extra: Label[] = []
704 if (live.isPaused) extra.push({ text: 'PAUSED', color: '#ffffff', age: 200 })
705 const isDone = game.isOver || live.result !== null
706 let overlay: Overlay | null = null
707 if (isDone && live.overMs > OVER_FINAL_MS) {
708 if (live.vs) overlay = vsOverlay(live.result === 'win', live.vs.match.steps, live.vs.gameId !== null)
709 else {
710 const text = live.result ? (live.result === 'win' ? 'YOU WIN' : 'YOU LOSE') : 'GAME OVER'
711 overlay = { title: text, color: live.result === 'win' ? '#38d64a' : '#ff3b3b', lines: [`SCORE ${game.score}`, live.mode === 'marathon' && live.gameId === null ? 'unranked' : '', live.mode === 'marathon' ? 'r restart q menu' : 'q menu'] }
712 }
713 }
714 const pending = game.pendingGarbage.reduce((n, g) => n + g.lines, 0)
715 const flash = fx.levelUp !== null && Math.floor(fx.levelUp / 100) % 2 === 0
716 const frame = flash ? 'yellow' : FRAME
717 const rows = fieldRows(game, fx, extra, live.overMs, size, overlay)
718 const warn = pending > 0 && Math.floor(live.t / 200) % 2 === 0
719 const barOn = (i: number) => rows.length - i <= pending * size.ch
720 const bars = rows.map((_, i) => <Text color={warn ? '#ff8080' : '#ff3b3b'}>{barOn(i) ? '█' : ' '}</Text>)
721 const width = FIELD_W * size.cw
722 const opp = isBattle ? drawOpponent(live, surface, tier !== 'mini') : null
723
724 if (tier === 'mini') {
725 const side = surface.columns >= TIERS.mini.columns
726 const hold = game.hold ? previewRows(game.hold, COMPACT, 10, !game.canHold) : blankRows(2, 10)
727 const sideEl = (
728 <Box flexDirection="column" width={10}>
729 <Text bold dimColor={!game.canHold} color={TITLE}>HOLD</Text>
730 {gridEl(hold, surface)}
731 <Text bold color={TITLE}>NEXT</Text>
732 {game.next.slice(0, 3).map((kind, i) => (
733 <Box flexDirection="column">
734 {i > 0 && <Text> </Text>}
735 {gridEl(previewRows(kind, COMPACT, 10), surface)}
736 </Box>
737 ))}
738 </Box>
739 )
740
741 return (
742 <Box flexDirection="column">
743 <Box flexDirection="row" gap={1}>
744 <Box flexDirection="row">
745 <Box flexDirection="column">{bars}</Box>
746 {gridEl(rows, surface)}
747 </Box>
748 {side && !isBattle ? sideEl : null}
749 {isBattle && surface.columns >= 21 + 1 + 10 ? opp : null}
750 </Box>
751 <Text color={live.hasKeyed ? undefined : 'yellow'}>{live.hasKeyed ? `${game.score} L${game.lines} Lv${game.level}` : activate(surface.columns)}</Text>
752 </Box>
753 )
754 }
755
756 return (
757 <Box flexDirection="row" gap={1}>
758 <Box flexDirection="column">
759 {drawTitle(live, surface, width + 3)}
760 <Box flexDirection="row">
761 <Box flexDirection="column" marginTop={1}>{bars}</Box>
762 <Box borderStyle="round" borderColor={frame}>
763 {gridEl(rows, surface)}
764 </Box>
765 </Box>
766 </Box>
767 {drawSide(live, surface, tier, pending)}
768 {opp ? <Box flexDirection="column" marginTop={1}>{opp}</Box> : null}
769 </Box>
770 )
771}
772
773function drawMenu(live: Live, surface: Surf) {
774 const { Box, Text } = surface.elements
775 const cols = surface.columns
776 const isBig = cols >= 37 && surface.rows >= 26
777 const top = live.props.leaderboard?.marathon[0]
778 const mw = cols >= 46 ? 44 : Math.max(20, cols - 2)
779 const inner = mw - 2
780 const GRAD = ['#00e5ff', '#2fb8ff', '#5a8cff', '#8a62ff', '#b44cff']
781 const word = (w: string, flip: boolean) =>
782 bigWord(w).map((line, i) => <Text bold color={GRAD[flip ? 4 - i : i]}>{line}</Text>)
783
784 return (
785 <Box flexDirection="column" alignItems="center" paddingX={1}>
786 {isBig ? (
787 <Box flexDirection="column">
788 {word('BLOCK', false)}
789 <Text> </Text>
790 {word('BATTLE', true)}
791 </Box>
792 ) : (
793 <Text bold color="cyan">BLOCK BATTLE</Text>
794 )}
795 <Text dimColor>Play while Claude works</Text>
796 <Box marginTop={1}>
797 {panel(
798 surface,
799 mw,
800 FRAME,
801 MENU.map((m, i) => {
802 const sel = i === live.menu
803 const text = (' ' + (sel ? '> ' : ' ') + m.label.padEnd(12) + (inner >= 38 ? m.hint : '')).padEnd(inner).slice(0, inner)
804
805 return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
806 }),
807 )}
808 </Box>
809 <Text dimColor>{top ? `Marathon best: ${top.login} ${top.score}` : ' '}</Text>
810 <Text dimColor>up/down choose enter starts</Text>
811 <Text dimColor>Esc hands the keys back</Text>
812 {live.hasKeyed ? <Text dimColor>keys: arrows up/x space c q</Text> : <Text bold color="yellow">{activate(cols)}</Text>}
813 </Box>
814 )
815}
816
817function drawStarting(live: Live, surface: Surf) {
818 const { Box, Text } = surface.elements
819
820 return (
821 <Box flexDirection="column" alignItems="center" paddingX={1}>
822 <Text bold color="cyan">{live.startingBot ? 'VS BOT' : 'MARATHON'}</Text>
823 <Text>{`Starting${'.'.repeat(1 + (Math.floor(live.t / 320) % 3))}`}</Text>
824 </Box>
825 )
826}
827
828function drawServers(live: Live, surface: Surf) {
829 const { Box, Text } = surface.elements
830 const w = Math.min(46, Math.max(24, surface.columns - 2))
831 const inner = w - 2
832 const choices = serverChoices(live)
833 const typing = live.typing
834
835 return (
836 <Box flexDirection="column" alignItems="center" paddingX={1}>
837 <Text bold color="cyan">BATTLE</Text>
838 <Text dimColor>Where do you want to play?</Text>
839 {panel(
840 surface,
841 w,
842 FRAME,
843 typing === null ? (
844 choices.map((c, i) => {
845 const sel = i === live.pick
846 const text = (' ' + (sel ? '> ' : ' ') + c.label).padEnd(inner).slice(0, inner)
847
848 return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
849 })
850 ) : (
851 <Box flexDirection="column" paddingX={1}>
852 <Text>Server address:</Text>
853 <Text color="cyan">{(typing + '_').slice(-(inner - 2))}</Text>
854 {live.typeError ? <Text color="red">{live.typeError}</Text> : <Text dimColor>enter connects, up goes back</Text>}
855 </Box>
856 ),
857 )}
858 <Text dimColor>{typing === null ? 'enter picks, q goes back' : ' '}</Text>
859 </Box>
860 )
861}
862
863function drawLevels(live: Live, surface: Surf) {
864 const { Box, Text } = surface.elements
865 const w = Math.min(46, Math.max(24, surface.columns - 2))
866 const inner = w - 2
867
868 return (
869 <Box flexDirection="column" alignItems="center" paddingX={1}>
870 <Text bold color="cyan">VS BOT</Text>
871 <Text dimColor>Pick a bot to beat</Text>
872 {panel(
873 surface,
874 w,
875 FRAME,
876 BOT_LEVELS.map((l, i) => {
877 const sel = i === live.pick
878 const text = (' ' + (sel ? '> ' : ' ') + l.name).padEnd(inner).slice(0, inner)
879
880 return <Text bold={sel} inverse={sel} color={sel ? 'cyan' : undefined}>{text}</Text>
881 }),
882 )}
883 <Text dimColor>enter starts, q goes back</Text>
884 </Box>
885 )
886}
887
888function drawLobby(live: Live, surface: Surf) {
889 const { Box, Text } = surface.elements
890 const { notice, battle } = live.props
891 const dots = '.'.repeat(1 + (Math.floor(live.t / 320) % 3))
892 const w = Math.min(46, Math.max(24, surface.columns - 2))
893 const showNotice = notice && battle.status === 'idle'
894 const on = battle.status === 'queueing' && !showNotice ? battle.online : null
895 const total = on ? `${on.playing + on.looking} online` : ''
896 const full = on ? `${total}: ${on.playing} in a battle, ${on.looking} looking` : ''
897
898 return (
899 <Box flexDirection="column" alignItems="center" paddingX={1}>
900 <Text bold color="cyan">BATTLE</Text>
901 {panel(
902 surface,
903 w,
904 FRAME,
905 <Box paddingX={1} flexDirection="column">
906 {showNotice ? <Text color="red">{notice}</Text> : <Text>{`Looking for an opponent${dots}`}</Text>}
907 {on && <Text dimColor>{full.length <= w - 4 ? full : total}</Text>}
908 </Box>,
909 )}
910 <Text dimColor>q goes back</Text>
911 </Box>
912 )
913}
914
915type Entry = { login: string; value: string }
916
917function board(surface: Surf, title: string, empty: string, entries: Entry[], width: number, me: string | null, num = 7, framed = true) {
918 const { Box, Text } = surface.elements
919 const inner = width - 2
920 const body = (
921 <Box flexDirection="column">
922 <Text bold color={TITLE}>{(' ' + title).slice(0, inner)}</Text>
923 {entries.length === 0 && <Text dimColor>{' ' + empty}</Text>}
924 {entries.map((r, i) => {
925 const mine = r.login === me
926 const name = r.login.slice(0, Math.max(4, inner - num - 4))
927 const text = (` ${i + 1} ${name}`.padEnd(inner - num) + r.value.padStart(num - 1) + ' ').slice(0, inner)
928
929 return <Text bold={mine} inverse={mine} color={mine ? 'yellow' : undefined}>{text}</Text>
930 })}
931 </Box>
932 )
933
934 return framed ? panel(surface, width, FRAME, body) : <Box flexDirection="column" width={width}>{body}</Box>
935}
936
937function drawLeaderboard(live: Live, surface: Surf) {
938 const { Box, Text } = surface.elements
939 const { leaderboard, notice, me } = live.props
940 const cols = surface.columns
941 const isBot = live.lbPage === 1
942 const wide = cols >= (isBot ? 82 : 62)
943 const w = wide ? (isBot ? 26 : 30) : Math.min(36, Math.max(24, cols - 2))
944 // Three stacked boards fit a short pane only without their frames.
945 const framed = !isBot || wide || surface.rows >= 30
946 const boards = !leaderboard
947 ? null
948 : isBot
949 ? BOT_LEVELS.map(l => board(surface, l.name, 'no wins yet', leaderboard.bot[l.id].map(r => ({ login: r.login, value: formatMs(r.ms) })), w, me, 10, framed))
950 : [
951 board(surface, 'Marathon top 5', 'no scores yet', leaderboard.marathon.map(r => ({ login: r.login, value: String(r.score) })), w, me),
952 board(surface, 'Battle wins top 5', 'no wins yet', leaderboard.wins.map(r => ({ login: r.login, value: String(r.wins) })), w, me),
953 ]
954
955 return (
956 <Box flexDirection="column" alignItems="center" paddingX={1}>
957 <Text bold color="cyan">LEADERBOARD</Text>
958 <Text dimColor>{hostOf(live.props.servers.active)}</Text>
959 {boards ? (
960 <Box flexDirection={wide ? 'row' : 'column'} gap={framed ? 1 : 0}>
961 {boards}
962 </Box>
963 ) : (
964 <Text color={notice ? 'red' : undefined}>{notice ?? 'Loading...'}</Text>
965 )}
966 <Text dimColor>arrows page r refresh q back</Text>
967 </Box>
968 )
969}
970
971const Game: ClientModule<GameView, Shell> = (props, surface) => {
972 let shell = surface.state
973 if (shell === undefined) {
974 const live = newLive(props)
975 shell = { live, rev: 0 }
976 const commit = () => surface.setState({ live, rev: ++live.rev })
977 surface.every(STEP_MS, () => {
978 const dirty = tick(live)
979 // One post per tick: a later post in the same frame would replace this one.
980 const msg = live.outbox.shift() ?? nextChunk(live)
981 if (msg) surface.post(msg)
982 if (dirty) commit()
983 })
984 surface.onKey(e => {
985 onKey(live, e.key)
986 commit()
987 })
988 surface.setState(shell)
989 }
990 const { live } = shell
991 live.props = props ?? emptyProps()
992
993 if (live.screen === 'play' && live.game) return drawPlay(live, surface)
994 if (live.screen === 'starting') return drawStarting(live, surface)
995 if (live.screen === 'servers') return drawServers(live, surface)
996 if (live.screen === 'levels') return drawLevels(live, surface)
997 if (live.screen === 'lobby') return drawLobby(live, surface)
998 if (live.screen === 'leaderboard') return drawLeaderboard(live, surface)
999
1000 return drawMenu(live, surface)
1001}
1002
1003export default Game
1004hooks/engine.ts 484 lines1export type Kind = 'I' | 'O' | 'T' | 'S' | 'Z' | 'J' | 'L'
2export type Cell = Kind | 'G' | null
3export type Mode = 'marathon' | 'battle'
4export type Input =
5 | 'left' | 'right' | 'softDropOn' | 'softDropOff' | 'softDropStep' | 'hardDrop'
6 | 'rotateCW' | 'rotateCCW' | 'rotate180' | 'hold'
7export type ClearKind = 'single' | 'double' | 'triple' | 'quad'
8 | 'tspinMini' | 'tspinMiniSingle' | 'tspinMiniDouble'
9 | 'tspin' | 'tspinSingle' | 'tspinDouble' | 'tspinTriple'
10export type GameEvent =
11 | { type: 'lineClear'; rows: number[]; kind: ClearKind; b2b: boolean; combo: number; perfectClear: boolean; attack: number }
12 | { type: 'lock' }
13 | { type: 'hardDrop'; from: number; to: number; columns: number[] }
14 | { type: 'hold' }
15 | { type: 'garbageIn'; lines: number }
16 | { type: 'levelUp'; level: number }
17 | { type: 'topOut' }
18export type Active = { kind: Kind; rotation: 0 | 1 | 2 | 3; x: number; y: number }
19export type Game = {
20 mode: Mode; seed: number; board: Cell[][]; active: Active | null; hold: Kind | null; canHold: boolean
21 next: Kind[]; score: number; lines: number; level: number; combo: number; b2b: boolean
22 pendingGarbage: { lines: number; hole: number }[]; isOver: boolean; elapsedMs: number
23 rng: number; bag: Kind[]; garbageCount: number
24 gravityMs: number; lockMs: number; resets: number; lowestY: number
25 softDrop: boolean; rotated: boolean; kickIdx: number
26}
27
28const W = 10
29const H = 40
30const VISIBLE_TOP = 20
31const LOCK_MS = 500
32const MAX_RESETS = 15
33const MAX_LEVEL = 15
34const KINDS: Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
35
36type Pt = readonly [number, number]
37const BASE: Record<Kind, Pt[]> = {
38 I: [[0, 1], [1, 1], [2, 1], [3, 1]],
39 O: [[1, 0], [2, 0], [1, 1], [2, 1]],
40 T: [[1, 0], [0, 1], [1, 1], [2, 1]],
41 S: [[1, 0], [2, 0], [0, 1], [1, 1]],
42 Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
43 J: [[0, 0], [0, 1], [1, 1], [2, 1]],
44 L: [[2, 0], [0, 1], [1, 1], [2, 1]],
45}
46const SHAPES = {} as Record<Kind, Pt[][]>
47for (const k of KINDS) {
48 const n = k === 'I' || k === 'O' ? 4 : 3
49 const rots: Pt[][] = [BASE[k]]
50 for (let r = 1; r < 4; r++) {
51 rots.push(k === 'O' ? BASE[k] : rots[r - 1]!.map(([x, y]) => [n - 1 - y, x] as Pt))
52 }
53 SHAPES[k] = rots
54}
55
56// Offsets are (dx, dy) with y pointing up, as published; applied as y - dy.
57const KICK_JLSTZ: Record<string, Pt[]> = {
58 '01': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
59 '10': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
60 '12': [[0, 0], [1, 0], [1, -1], [0, 2], [1, 2]],
61 '21': [[0, 0], [-1, 0], [-1, 1], [0, -2], [-1, -2]],
62 '23': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
63 '32': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
64 '30': [[0, 0], [-1, 0], [-1, -1], [0, 2], [-1, 2]],
65 '03': [[0, 0], [1, 0], [1, 1], [0, -2], [1, -2]],
66}
67const KICK_I: Record<string, Pt[]> = {
68 '01': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
69 '10': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
70 '12': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
71 '21': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
72 '23': [[0, 0], [2, 0], [-1, 0], [2, 1], [-1, -2]],
73 '32': [[0, 0], [-2, 0], [1, 0], [-2, -1], [1, 2]],
74 '30': [[0, 0], [1, 0], [-2, 0], [1, -2], [-2, 1]],
75 '03': [[0, 0], [-1, 0], [2, 0], [-1, 2], [2, -1]],
76}
77// Not guideline: the common SRS+ style 180 kicks, shared by all pieces.
78const KICK_180: Record<string, Pt[]> = {
79 '02': [[0, 0], [0, 1], [1, 1], [-1, 1], [1, 0], [-1, 0]],
80 '20': [[0, 0], [0, -1], [-1, -1], [1, -1], [-1, 0], [1, 0]],
81 '13': [[0, 0], [1, 0], [1, 2], [1, 1], [0, 2], [0, 1]],
82 '31': [[0, 0], [-1, 0], [-1, 2], [-1, 1], [0, 2], [0, 1]],
83}
84
85const LINE_SCORE: Record<ClearKind, number> = {
86 single: 100, double: 300, triple: 500, quad: 800,
87 tspinMini: 100, tspinMiniSingle: 200, tspinMiniDouble: 400,
88 tspin: 400, tspinSingle: 800, tspinDouble: 1200, tspinTriple: 1600,
89}
90const LINE_ATTACK: Record<ClearKind, number> = {
91 single: 0, double: 1, triple: 2, quad: 4,
92 tspinMini: 0, tspinMiniSingle: 0, tspinMiniDouble: 1,
93 tspin: 0, tspinSingle: 2, tspinDouble: 4, tspinTriple: 6,
94}
95const COMBO_ATTACK = [0, 0, 1, 1, 1, 2, 2, 3, 3, 4, 4, 4, 5]
96const PC_SCORE = [0, 800, 1200, 1800, 2000]
97const PC_B2B_QUAD = 3200
98const PC_ATTACK = 10
99
100function mix(s: number): number {
101 let t = Math.imul(s ^ (s >>> 15), 1 | s)
102 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
103 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
104}
105
106function rand(g: Game): number {
107 g.rng = (g.rng + 0x6d2b79f5) | 0
108 return mix(g.rng)
109}
110
111function draw(g: Game): Kind {
112 if (g.bag.length === 0) {
113 const bag = KINDS.slice()
114 for (let i = bag.length - 1; i > 0; i--) {
115 const j = Math.floor(rand(g) * (i + 1))
116 const t = bag[i]!
117 bag[i] = bag[j]!
118 bag[j] = t
119 }
120 g.bag = bag
121 }
122 return g.bag.pop()!
123}
124
125function clone(g: Game): Game {
126 return {
127 ...g,
128 board: g.board.map(r => r.slice()),
129 active: g.active && { ...g.active },
130 next: g.next.slice(),
131 bag: g.bag.slice(),
132 pendingGarbage: g.pendingGarbage.map(p => ({ ...p })),
133 }
134}
135
136function emptyRow(): Cell[] {
137 return new Array<Cell>(W).fill(null)
138}
139
140function collides(board: Cell[][], kind: Kind, rotation: number, x: number, y: number): boolean {
141 for (const [cx, cy] of SHAPES[kind][rotation]!) {
142 const px = x + cx
143 const py = y + cy
144 if (px < 0 || px >= W || py < 0 || py >= H || board[py]![px] != null) return true
145 }
146 return false
147}
148
149function grounded(g: Game): boolean {
150 const a = g.active!
151 return collides(g.board, a.kind, a.rotation, a.x, a.y + 1)
152}
153
154export function cellsOf(active: Active): [number, number][] {
155 return SHAPES[active.kind][active.rotation]!.map(([cx, cy]) => [active.x + cx, active.y + cy])
156}
157
158export function ghostY(game: Game): number | null {
159 const a = game.active
160 if (!a) return null
161 let y = a.y
162 while (!collides(game.board, a.kind, a.rotation, a.x, y + 1)) y++
163 return y
164}
165
166export function newGame(mode: Mode, seed: number): Game {
167 const g: Game = {
168 mode, seed, board: Array.from({ length: H }, emptyRow), active: null, hold: null, canHold: true,
169 next: [], score: 0, lines: 0, level: 1, combo: -1, b2b: false,
170 pendingGarbage: [], isOver: false, elapsedMs: 0,
171 rng: seed | 0, bag: [], garbageCount: 0,
172 gravityMs: 0, lockMs: 0, resets: 0, lowestY: 0,
173 softDrop: false, rotated: false, kickIdx: 0,
174 }
175 const first = draw(g)
176 for (let i = 0; i < 5; i++) g.next.push(draw(g))
177 spawn(g, first, [])
178 return g
179}
180
181function topOut(g: Game, events: GameEvent[]) {
182 g.isOver = true
183 g.active = null
184 events.push({ type: 'topOut' })
185}
186
187function spawn(g: Game, kind: Kind, events: GameEvent[]) {
188 let y = 18
189 if (collides(g.board, kind, 0, 3, y)) {
190 g.active = null
191 topOut(g, events)
192 return
193 }
194 if (!collides(g.board, kind, 0, 3, y + 1)) y++
195 g.active = { kind, rotation: 0, x: 3, y }
196 g.rotated = false
197 g.kickIdx = 0
198 g.lockMs = 0
199 g.resets = 0
200 g.gravityMs = 0
201 g.lowestY = y
202}
203
204function nextPiece(g: Game, events: GameEvent[]) {
205 const kind = g.next.shift()!
206 g.next.push(draw(g))
207 spawn(g, kind, events)
208}
209
210function afterMove(g: Game, wasGrounded: boolean) {
211 const a = g.active!
212 if (a.y > g.lowestY) {
213 g.lowestY = a.y
214 g.resets = 0
215 }
216 if (!wasGrounded) return
217 if (!grounded(g)) {
218 g.lockMs = 0
219 } else if (g.resets < MAX_RESETS) {
220 g.lockMs = 0
221 g.resets++
222 }
223}
224
225function tryMove(g: Game, dx: number) {
226 const a = g.active!
227 if (collides(g.board, a.kind, a.rotation, a.x + dx, a.y)) return
228 const was = grounded(g)
229 a.x += dx
230 g.rotated = false
231 afterMove(g, was)
232}
233
234function rotate(g: Game, turn: 1 | 2 | 3) {
235 const a = g.active!
236 if (a.kind === 'O') return
237 const to = ((a.rotation + turn) & 3) as 0 | 1 | 2 | 3
238 const key = `${a.rotation}${to}`
239 const list = turn === 2 ? KICK_180[key]! : (a.kind === 'I' ? KICK_I : KICK_JLSTZ)[key]!
240 const was = grounded(g)
241 for (let i = 0; i < list.length; i++) {
242 const [dx, dy] = list[i]!
243 if (collides(g.board, a.kind, to, a.x + dx, a.y - dy)) continue
244 a.rotation = to
245 a.x += dx
246 a.y -= dy
247 g.rotated = true
248 g.kickIdx = turn === 2 ? 0 : i
249 afterMove(g, was)
250 return
251 }
252}
253
254function stepDown(g: Game) {
255 const a = g.active!
256 a.y++
257 g.rotated = false
258 if (a.y > g.lowestY) {
259 g.lowestY = a.y
260 g.resets = 0
261 }
262}
263
264function pushGarbage(g: Game, lines: number, hole: number): boolean {
265 const lost = g.board.slice(0, lines).some(r => r.some(c => c != null))
266 const rows: Cell[][] = []
267 for (let i = 0; i < lines; i++) rows.push(Array.from({ length: W }, (_, c): Cell => (c === hole ? null : 'G')))
268 g.board = g.board.slice(lines).concat(rows)
269 return lost
270}
271
272function lockPiece(g: Game, events: GameEvent[]) {
273 const a = g.active!
274 const cells = cellsOf(a)
275 let spin: 'none' | 'mini' | 'full' = 'none'
276 if (a.kind === 'T' && g.rotated) {
277 const filled = ([[0, 0], [2, 0], [0, 2], [2, 2]] as const).map(([cx, cy]) => {
278 const x = a.x + cx
279 const y = a.y + cy
280 return x < 0 || x >= W || y < 0 || y >= H || g.board[y]![x] != null
281 })
282 if (filled.filter(Boolean).length >= 3) {
283 const front = [[0, 1], [1, 3], [2, 3], [0, 2]][a.rotation]!
284 spin = filled[front[0]!] && filled[front[1]!] || g.kickIdx === 4 ? 'full' : 'mini'
285 }
286 }
287 for (const [x, y] of cells) g.board[y]![x] = a.kind
288 g.active = null
289 g.canHold = true
290 events.push({ type: 'lock' })
291
292 const rows: number[] = []
293 for (let y = 0; y < H; y++) if (g.board[y]!.every(c => c != null)) rows.push(y)
294 const n = rows.length
295
296 if (n === 0 && cells.every(([, y]) => y < VISIBLE_TOP)) {
297 topOut(g, events)
298 return
299 }
300
301 if (n > 0) {
302 g.board = g.board.filter((_, y) => !rows.includes(y))
303 while (g.board.length < H) g.board.unshift(emptyRow())
304 }
305
306 const level = g.level
307 let kind: ClearKind | null = null
308 if (spin === 'none') kind = n === 0 ? null : (['single', 'double', 'triple', 'quad'] as const)[n - 1]!
309 else if (spin === 'mini') kind = (['tspinMini', 'tspinMiniSingle', 'tspinMiniDouble', 'tspinTriple'] as const)[n]!
310 else kind = (['tspin', 'tspinSingle', 'tspinDouble', 'tspinTriple'] as const)[n]!
311
312 let attack = 0
313 if (kind) {
314 const difficult = n > 0 && (kind === 'quad' || spin !== 'none')
315 const b2bApplied = difficult && g.b2b
316 let points = Math.floor(LINE_SCORE[kind] * level * (b2bApplied ? 1.5 : 1))
317 let perfect = false
318 if (n > 0) {
319 g.combo++
320 points += 50 * g.combo * level
321 perfect = g.board.every(r => r.every(c => c == null))
322 if (perfect) points += (n === 4 && b2bApplied ? PC_B2B_QUAD : PC_SCORE[n]!) * level
323 g.b2b = difficult
324 if (g.mode === 'battle') {
325 attack = perfect
326 ? PC_ATTACK
327 : LINE_ATTACK[kind] + (b2bApplied ? 1 : 0) + COMBO_ATTACK[Math.min(g.combo, COMBO_ATTACK.length - 1)]!
328 while (attack > 0 && g.pendingGarbage.length > 0) {
329 const p = g.pendingGarbage[0]!
330 if (p.lines <= attack) {
331 attack -= p.lines
332 g.pendingGarbage.shift()
333 } else {
334 p.lines -= attack
335 attack = 0
336 }
337 }
338 }
339 } else {
340 g.combo = -1
341 }
342 g.score += points
343 events.push({ type: 'lineClear', rows, kind, b2b: b2bApplied, combo: g.combo, perfectClear: perfect, attack })
344 } else {
345 g.combo = -1
346 }
347
348 if (n > 0) {
349 g.lines += n
350 const lv = Math.min(MAX_LEVEL, 1 + Math.floor(g.lines / 10))
351 if (lv > g.level) {
352 g.level = lv
353 events.push({ type: 'levelUp', level: lv })
354 }
355 } else if (g.pendingGarbage.length > 0) {
356 let total = 0
357 let lost = false
358 for (const p of g.pendingGarbage) {
359 total += p.lines
360 if (pushGarbage(g, p.lines, p.hole)) lost = true
361 }
362 g.pendingGarbage = []
363 events.push({ type: 'garbageIn', lines: total })
364 if (lost) {
365 topOut(g, events)
366 return
367 }
368 }
369 nextPiece(g, events)
370}
371
372function gravityMs(level: number): number {
373 return 1000 * Math.pow(0.8 - (level - 1) * 0.007, level - 1)
374}
375
376function advance(g: Game, dt: number, events: GameEvent[]) {
377 let rem = dt
378 for (let guard = 0; rem > 0 && g.active && !g.isOver && guard < 1000; guard++) {
379 if (grounded(g)) {
380 g.gravityMs = 0
381 const need = LOCK_MS - g.lockMs
382 if (rem >= need) {
383 rem -= need
384 lockPiece(g, events)
385 } else {
386 g.lockMs += rem
387 rem = 0
388 }
389 continue
390 }
391 g.lockMs = 0
392 const iv = gravityMs(g.level) / (g.softDrop ? 20 : 1)
393 g.gravityMs += rem
394 rem = 0
395 while (g.gravityMs >= iv && !grounded(g)) {
396 stepDown(g)
397 g.gravityMs -= iv
398 if (g.softDrop) g.score++
399 }
400 if (grounded(g)) {
401 rem = g.gravityMs
402 g.gravityMs = 0
403 }
404 }
405}
406
407function apply(g: Game, input: Input, events: GameEvent[]) {
408 const a = g.active!
409 switch (input) {
410 case 'left': return tryMove(g, -1)
411 case 'right': return tryMove(g, 1)
412 case 'softDropOn': g.softDrop = true; return
413 case 'softDropOff': g.softDrop = false; return
414 case 'softDropStep':
415 if (!grounded(g)) {
416 stepDown(g)
417 g.score++
418 g.gravityMs = 0
419 }
420 return
421 case 'rotateCW': return rotate(g, 1)
422 case 'rotateCCW': return rotate(g, 3)
423 case 'rotate180': return rotate(g, 2)
424 case 'hardDrop': {
425 const from = a.y
426 while (!grounded(g)) {
427 a.y++
428 g.score += 2
429 }
430 if (a.y !== from) g.rotated = false
431 const columns = [...new Set(cellsOf(a).map(([x]) => x))].sort((p, q) => p - q)
432 events.push({ type: 'hardDrop', from, to: a.y, columns })
433 lockPiece(g, events)
434 return
435 }
436 case 'hold': {
437 if (!g.canHold) return
438 const cur = a.kind
439 g.canHold = false
440 events.push({ type: 'hold' })
441 if (g.hold === null) {
442 g.hold = cur
443 nextPiece(g, events)
444 } else {
445 const h = g.hold
446 g.hold = cur
447 spawn(g, h, events)
448 }
449 return
450 }
451 }
452}
453
454export function step(game: Game, inputs: Input[], dtMs: number): { game: Game; events: GameEvent[] } {
455 if (game.isOver) return { game, events: [] }
456 const g = clone(game)
457 const events: GameEvent[] = []
458 for (const input of inputs) {
459 if (g.isOver || !g.active) break
460 apply(g, input, events)
461 }
462 if (!g.isOver && dtMs > 0) advance(g, dtMs, events)
463 g.elapsedMs += dtMs
464 return { game: g, events }
465}
466
467export function receiveGarbage(game: Game, lines: number): Game {
468 const g = clone(game)
469 if (g.mode !== 'battle' || lines <= 0 || g.isOver) return g
470 // Holes come from their own stream so garbage never shifts the 7-bag both players share.
471 g.garbageCount++
472 g.pendingGarbage.push({ lines, hole: Math.floor(mix((g.seed + Math.imul(g.garbageCount, 0x9e3779b9)) | 0) * W) })
473 return g
474}
475
476// 20 visible rows of 10 chars, top row first, concatenated with no separator; the falling piece is included.
477export function snapshot(game: Game): string {
478 const rows = game.board.slice(VISIBLE_TOP).map(r => r.map(c => c ?? '.'))
479 if (game.active) {
480 for (const [x, y] of cellsOf(game.active)) if (y >= VISIBLE_TOP) rows[y - VISIBLE_TOP]![x] = game.active.kind
481 }
482 return rows.map(r => r.join('')).join('')
483}
484hooks/match.ts 34 lines1import { newGame, receiveGarbage, step } from './engine.ts'
2import type { Game, GameEvent, Input } from './engine.ts'
3import { botStep, newBot } from './bot.ts'
4import type { Bot, Level } from './bot.ts'
5
6const STEP_MS = 16
7
8export type Match = { me: Game; bot: Game; brain: Bot; steps: number; winner: 'me' | 'bot' | null }
9
10export const newMatch = (seed: number, level: Level): Match => ({
11 me: newGame('battle', seed),
12 bot: newGame('battle', seed),
13 brain: newBot(level, seed),
14 steps: 0,
15 winner: null,
16})
17
18export function stepMatch(m: Match, inputs: readonly Input[]): { me: GameEvent[]; bot: GameEvent[] } {
19 if (m.winner) return { me: [], bot: [] }
20 const a = step(m.me, [...inputs], STEP_MS)
21 const b = botStep(m.brain, m.bot, STEP_MS)
22 let me = a.game
23 let bot = b.game
24 for (const e of a.events) if (e.type === 'lineClear' && e.attack > 0) bot = receiveGarbage(bot, e.attack)
25 for (const e of b.events) if (e.type === 'lineClear' && e.attack > 0) me = receiveGarbage(me, e.attack)
26 m.me = me
27 m.bot = bot
28 m.steps++
29 if (me.isOver) m.winner = 'bot'
30 else if (bot.isOver) m.winner = 'me'
31
32 return { me: a.events, bot: b.events }
33}
34hooks/bot.ts 297 lines1// The .ts extensions let the server run a byte-identical copy under Node.
2import { cellsOf, step } from './engine.ts'
3import type { Game, GameEvent, Input, Kind } from './engine.ts'
4
5export type Level = 'easy' | 'medium' | 'hard'
6export const LEVELS: readonly Level[] = ['easy', 'medium', 'hard']
7
8type Tuning = { pace: number; hold: boolean; lookahead: boolean; slip: number; slipTo: 'top5' | 'second' | 'none'; top: number }
9
10// pace: steps between inputs. slip: pieces per thousand that take a worse placement.
11// top: the engine level whose gravity the bot's board never goes past.
12export const TUNING: Record<Level, Tuning> = {
13 easy: { pace: 12, hold: false, lookahead: false, slip: 300, slipTo: 'top5', top: 5 },
14 medium: { pace: 5, hold: true, lookahead: false, slip: 50, slipTo: 'second', top: 8 },
15 hard: { pace: 3, hold: false, lookahead: true, slip: 0, slipTo: 'none', top: 10 },
16}
17
18// Placements scored per step, so a long think spreads over steps instead of stalling one.
19export const SEARCH_PER_STEP = 64
20const LOOKAHEAD_KEEP = 5
21// From this level a piece is on the stack before it can shift, so plans must be reachable by sliding.
22const FAST_LEVEL = 14
23
24// Integer weights: integer arithmetic is exact on every JS engine, so the server agrees.
25const W_HEIGHT = -510
26const W_LINES = 761
27const W_HOLES = -357
28const W_BUMP = -184
29const DEAD = -1_000_000_000
30
31const H = 40
32const W = 10
33const FULL = (1 << W) - 1
34const SPAWN_X = 3
35const SPAWN_Y = 18
36const VISIBLE_TOP = 20
37const KINDS: readonly Kind[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L']
38
39// m[dy] is the piece's row dy as a bitmask of board columns; low is its lowest dy.
40type Place = { x: number; m: number[]; low: number }
41const PLACES = {} as Record<Kind, Place[][]>
42for (const kind of KINDS) {
43 PLACES[kind] = ([0, 1, 2, 3] as const).map(rotation => {
44 const cells = cellsOf({ kind, rotation, x: 0, y: 0 })
45 const xs = cells.map(([x]) => x)
46 const low = Math.max(...cells.map(([, y]) => y))
47 const out: Place[] = []
48 for (let x = -Math.min(...xs); x + Math.max(...xs) < W; x++) {
49 const m = [0, 0, 0, 0]
50 for (const [cx, cy] of cells) m[cy] = m[cy]! | (1 << (x + cx))
51 out.push({ x, m, low })
52 }
53 return out
54 })
55}
56
57type Cand = { kind: Kind; hold: boolean; rot: number; place: Place }
58type Scored = { c: Cand; score: number; rows: number[]; lines: number; i: number }
59type Job = { parent: number; c: Cand }
60type Think = { base: number[]; fast: boolean; jobs: Job[]; at: number; first: Scored[]; deep: number[]; stage: 1 | 2 }
61type Plan = { hold: boolean; held: boolean; rot: number; x: number; rescued: boolean }
62
63export type Bot = {
64 level: Level
65 rng: number
66 wait: number
67 think: Think | null
68 plan: Plan | null
69 last: { input: Input; x: number; rot: number } | null
70}
71
72export const newBot = (level: Level, seed: number): Bot => ({ level, rng: (seed ^ 0x2545f491) | 0, wait: 0, think: null, plan: null, last: null })
73
74function rand32(b: Bot): number {
75 b.rng = (b.rng + 0x6d2b79f5) | 0
76 let t = Math.imul(b.rng ^ (b.rng >>> 15), 1 | b.rng)
77 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
78 return (t ^ (t >>> 14)) >>> 0
79}
80
81function toRows(board: Game['board']): number[] {
82 return board.map(r => {
83 let m = 0
84 for (let x = 0; x < W; x++) if (r[x] != null) m |= 1 << x
85 return m
86 })
87}
88
89function fits(rows: readonly number[], m: readonly number[], y: number): boolean {
90 for (let dy = 0; dy < 4; dy++) {
91 if (m[dy] === 0) continue
92 const r = y + dy
93 if (r >= H || (rows[r]! & m[dy]!) !== 0) return false
94 }
95 return true
96}
97
98// Straight down from the spawn row. dead: the engine would top out on this lock.
99function land(rows: readonly number[], p: Place, from = SPAWN_Y): { rows: number[]; lines: number; dead: boolean } | null {
100 let y = from
101 if (!fits(rows, p.m, y)) return null
102 while (fits(rows, p.m, y + 1)) y++
103 const placed = rows.slice()
104 for (let dy = 0; dy < 4; dy++) if (p.m[dy]) placed[y + dy] = placed[y + dy]! | p.m[dy]!
105 const kept = placed.filter(r => r !== FULL)
106 const lines = H - kept.length
107 while (kept.length < H) kept.unshift(0)
108 return { rows: kept, lines, dead: lines === 0 && y + p.low < VISIBLE_TOP }
109}
110
111// Turned at the spawn column, then moved one column at a time while resting on the stack.
112function slides(rows: readonly number[], c: Cand): boolean {
113 const list = PLACES[c.kind][c.rot]!
114 const to = c.place.x - list[0]!.x
115 let i = SPAWN_X - list[0]!.x
116 let y = SPAWN_Y
117 if (!fits(rows, list[i]!.m, y)) return false
118 while (i !== to) {
119 while (fits(rows, list[i]!.m, y + 1)) y++
120 const n = i < to ? i + 1 : i - 1
121 if (!fits(rows, list[n]!.m, y)) return false
122 i = n
123 }
124 return true
125}
126
127function evaluate(rows: readonly number[], lines: number): number {
128 let agg = 0
129 let holes = 0
130 let bump = 0
131 let prev = -1
132 for (let c = 0; c < W; c++) {
133 const bit = 1 << c
134 let top = H
135 for (let y = 0; y < H; y++) {
136 if (rows[y]! & bit) {
137 top = y
138 break
139 }
140 }
141 for (let y = top + 1; y < H; y++) if (!(rows[y]! & bit)) holes++
142 const h = H - top
143 agg += h
144 if (prev >= 0) bump += Math.abs(h - prev)
145 prev = h
146 }
147 return W_HEIGHT * agg + W_LINES * lines + W_HOLES * holes + W_BUMP * bump
148}
149
150function candidates(kind: Kind, hold: boolean): Cand[] {
151 const out: Cand[] = []
152 for (let rot = 0; rot < (kind === 'O' ? 1 : 4); rot++) for (const place of PLACES[kind][rot]!) out.push({ kind, hold, rot, place })
153 return out
154}
155
156function startThink(b: Bot, game: Game): Think {
157 const a = game.active!
158 const cands = candidates(a.kind, false)
159 if (TUNING[b.level].hold && game.canHold) {
160 const alt = game.hold ?? game.next[0]
161 if (alt && alt !== a.kind) cands.push(...candidates(alt, true))
162 }
163 return { base: toRows(game.board), fast: game.level >= FAST_LEVEL, jobs: cands.map(c => ({ parent: -1, c })), at: 0, first: [], deep: [], stage: 1 }
164}
165
166function runJob(th: Think, job: Job) {
167 if (job.parent < 0) {
168 if (th.fast && !slides(th.base, job.c)) return
169 const r = land(th.base, job.c.place)
170 if (!r) return
171 th.first.push({ c: job.c, score: r.dead ? DEAD : evaluate(r.rows, r.lines), rows: r.rows, lines: r.lines, i: th.first.length })
172 return
173 }
174 const p = th.first[job.parent]!
175 if (p.score === DEAD || (th.fast && !slides(p.rows, job.c))) return
176 const r = land(p.rows, job.c.place)
177 if (!r || r.dead) return
178 const s = evaluate(r.rows, p.lines + r.lines)
179 if (s > th.deep[job.parent]!) th.deep[job.parent] = s
180}
181
182function choose(b: Bot, th: Think, game: Game): Plan {
183 let order = th.first
184 if (th.stage === 2) {
185 const k = Math.min(LOOKAHEAD_KEEP, order.length)
186 const top = order
187 .slice(0, k)
188 .map((s, i) => ({ s, d: th.deep[i]!, i }))
189 .sort((p, q) => q.d - p.d || p.i - q.i)
190 .map(t => t.s)
191 order = [...top, ...order.slice(k)]
192 }
193 const t = TUNING[b.level]
194 const u1 = rand32(b)
195 const u2 = rand32(b)
196 let pick = 0
197 if (order.length > 1 && u1 % 1000 < t.slip) pick = t.slipTo === 'second' ? 1 : u2 % Math.min(5, order.length)
198 const c = order[pick]?.c
199 const a = game.active!
200 return c ? { hold: c.hold, held: false, rot: c.rot, x: c.place.x, rescued: false } : { hold: false, held: false, rot: a.rotation, x: a.x, rescued: true }
201}
202
203// True once a plan is ready.
204function thinkStep(b: Bot, game: Game): boolean {
205 const th = b.think ?? (b.think = startThink(b, game))
206 for (let n = 0; n < SEARCH_PER_STEP && th.at < th.jobs.length; n++) runJob(th, th.jobs[th.at++]!)
207 if (th.at < th.jobs.length) return false
208 if (th.stage === 1) {
209 th.first.sort((p, q) => q.score - p.score || p.i - q.i)
210 const next = game.next[0]
211 if (TUNING[b.level].lookahead && next) {
212 th.stage = 2
213 th.deep = th.first.map(() => DEAD)
214 const k = Math.min(LOOKAHEAD_KEEP, th.first.length)
215 for (let i = 0; i < k; i++) for (const c of candidates(next, false)) th.jobs.push({ parent: i, c })
216 if (th.at < th.jobs.length) return false
217 }
218 }
219 b.plan = choose(b, th, game)
220 b.think = null
221 return true
222}
223
224// Blocked on the way, usually by fast gravity: retarget to the best column the piece can still slide to.
225function rescue(game: Game): Plan {
226 const a = game.active!
227 const rows = toRows(game.board)
228 const list = PLACES[a.kind][a.rotation]!
229 const at = a.x - list[0]!.x
230 let best = at
231 let bestScore = DEAD
232 const consider = (i: number) => {
233 const r = land(rows, list[i]!, a.y)
234 const s = !r || r.dead ? DEAD : evaluate(r.rows, r.lines)
235 if (s > bestScore) {
236 best = i
237 bestScore = s
238 }
239 }
240 consider(at)
241 for (let i = at - 1; i >= 0 && fits(rows, list[i]!.m, a.y); i--) consider(i)
242 for (let i = at + 1; i < list.length && fits(rows, list[i]!.m, a.y); i++) consider(i)
243 return { hold: false, held: false, rot: a.rotation, x: list[best]!.x, rescued: true }
244}
245
246const TURN: Record<number, Input> = { 1: 'rotateCW', 2: 'rotate180', 3: 'rotateCCW' }
247
248// Steers the live piece toward the plan. A move or turn that changed nothing means it is blocked:
249// the first time it retargets, after that it drops.
250function nextInput(b: Bot, game: Game): Input {
251 const a = game.active!
252 const last = b.last
253 const isStuck = last !== null && last.input !== 'hold' && last.x === a.x && last.rot === a.rotation
254 const giveUp = isStuck && b.plan!.rescued
255 if (isStuck && !giveUp) b.plan = rescue(game)
256 const p = b.plan!
257 let input: Input
258 if (p.hold && !p.held && game.canHold) {
259 input = 'hold'
260 p.held = true
261 } else if (giveUp || (a.rotation === p.rot && a.x === p.x)) input = 'hardDrop'
262 else if (a.rotation !== p.rot) input = TURN[(p.rot - a.rotation) & 3]!
263 else input = a.x < p.x ? 'right' : 'left'
264 b.last = { input, x: a.x, rot: a.rotation }
265 return input
266}
267
268export function botInputs(b: Bot, game: Game): Input[] {
269 if (game.isOver || !game.active) return []
270 if (b.wait > 0) b.wait--
271 if (!b.plan && !thinkStep(b, game)) return []
272 if (b.wait > 0) return []
273 b.wait = TUNING[b.level].pace
274 return [nextInput(b, game)]
275}
276
277// Without a top speed the bot's own line clears raise its level until pieces outrun its pace.
278const capSpeed = (b: Bot, game: Game): Game => (game.level > TUNING[b.level].top ? { ...game, level: TUNING[b.level].top } : game)
279
280// One step of the bot's board. Inputs and gravity run as two engine steps so a clear that raises
281// the level is capped before the next piece falls. A lock by gravity alone can still spend the
282// rest of its step at the engine's level.
283export function botStep(b: Bot, game: Game, dtMs: number): { game: Game; events: GameEvent[] } {
284 const moved = step(game, botInputs(b, game), 0)
285 const fell = step(capSpeed(b, moved.game), [], dtMs)
286 const events = [...moved.events, ...fell.events]
287 botSaw(b, events)
288 return { game: capSpeed(b, fell.game), events }
289}
290
291export function botSaw(b: Bot, events: readonly GameEvent[]): void {
292 if (!events.some(e => e.type === 'lock' || e.type === 'topOut')) return
293 b.plan = null
294 b.think = null
295 b.last = null
296}
297types/index.d.ts 58 lines1export type ScoreRow = { login: string; score: number; lines: number; level: number; at: number }
2export type WinRow = { login: string; wins: number }
3export type BotLevel = 'easy' | 'medium' | 'hard'
4export type BotRow = { login: string; ms: number; at: number }
5export type Leaderboard = { marathon: ScoreRow[]; wins: WinRow[]; bot: Record<BotLevel, BotRow[]> }
6
7export type Incoming = { id: number; lines: number }
8export type Opponent = { login: string; snapshot: string; isOver: boolean }
9export type Online = { playing: number; looking: number }
10
11export type Battle = {
12 status: 'idle' | 'queueing' | 'matched' | 'ended'
13 roomId: string | null
14 seed: number
15 opponent: Opponent | null
16 // Every attack received this match, by server id; the Client applies each id once.
17 incoming: Incoming[]
18 result: 'win' | 'loss' | null
19 // Server-wide counts from the latest queue reply of this search; null until one carries them.
20 online: Online | null
21}
22
23export type GameView = {
24 me: string | null
25 leaderboard: Leaderboard | null
26 // Short human line shown instead of the leaderboard or lobby when the server is unreachable.
27 notice: string | null
28 battle: Battle
29 servers: Servers
30 marathon: { nonce: number; gameId: string | null; seed: number } | null
31 vsbot: { nonce: number; level: BotLevel; gameId: string | null; seed: number } | null
32 uploaded: { key: string; have: number } | null
33}
34
35export type Servers = {
36 // The plugin's configured server, the official one unless the player changed the setting.
37 home: string
38 last: string | null
39 // Where Battle and the leaderboard talk to right now.
40 active: string
41}
42
43// Everything a Client posts to the hooks side. Attacks ride inside `sync`: a post
44// replaces an undelivered one within a frame, so a separate message could be lost.
45export type ClientMsg =
46 | { type: 'menu'; choice: 'leaderboard' | 'back' }
47 | { type: 'menu'; choice: 'marathon'; nonce: number }
48 | { type: 'menu'; choice: 'bot'; level: BotLevel; nonce: number }
49 | { type: 'menu'; choice: 'battle'; server: string }
50 | { type: 'sync'; seq: number; attacks: number[]; snapshot: string; isOver: boolean }
51 | { type: 'log'; kind: 'marathon' | 'battle' | 'bot'; key: string; steps: number; inputsLen: number; total: number; at: number; values: number[] }
52
53declare module 'claude-code' {
54 interface PluginState {
55 'block-battle': { view: GameView }
56 }
57}
58