SLOPSHOPPER

sidequest

Sidequest for Claude Code: play Snake in a side pane while Claude does the main quest; works in any terminal

newpanebandguardcommandtimer
v0.1.0MITupdated 2026-10-04farhad-aman/claude-sidequest
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sidequest
│ ┃ sidequest ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./frame.js │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sidequest │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · sidequest
▣ client module ./frame.js
README

Sidequest for Claude Code

Claude does the main quest. You do a side quest: Snake in a side pane while Claude works. Text only, so it works in any terminal, Warp included.

Plugin id: sidequest.

  • After Claude has worked for 2 seconds the pane opens, paused.
  • Click the board once, then press an arrow (or WASD) to play. Space pauses.
  • When Claude needs you, the pane closes at once. When Claude is done, it counts down 3 seconds and closes. The game waits, paused, for next time.
  • In a terminal under 144 columns, press 1 on the line above the prompt to open it.

Commands:

  • /sidequest opens the pane with the last game.
  • /sidequest snake opens Snake.
  • /sidequest off stops it opening by itself. /sidequest on turns that back on.

With 2 or more games, press m in the pane to pick a game.

Add a game

  1. Make a folder hooks/games/<id>/ with an index.js.
  2. Its default export is an object with these parts. The state must be plain JSON data, because it is saved.
PartWhat it does
id, title, hintName in the command, name on screen, help line
fits(size)size is { columns, rows } of the play area
start(size)A new game
restore(saved, size)The saved game, or null
key(state, key)The next state, or null for a key the game does not use
stepMs(state)Time between moves in ms, or null
step(state)One move
score(state), isOver(state)For the status line
draw(state, { Box, Text })The play area, walls included
  1. Add it to the list in hooks/games/index.js.
  2. Run the tests. tests/games.test.ts checks every game in the list.

The frame owns Space (pause) and m (menu). Esc gives the focus back to the terminal, so no game can use it.

Load it

git clone https://github.com/farhad-aman/claude-sidequest ~/projects/sidequest

Add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, then restart Claude Code:

"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/projects/sidequest" }

Test it

claude plugin test ~/projects/sidequest
claude plugin validate ~/projects/sidequest

License

MIT

Source 6 files
hooks/register.js 252 lines
1import { GAMES, gameById } from './games/index.js'
2
3const PANE = 'sidequest'
4const DROP_IN_DELAY_MS = 2000
5const COUNTDOWN_SECONDS = 3
6const OLD_KEYS = { best: 'best:snake', game: 'game:snake' }
7
8let isOn = true
9let currentGame = GAMES[0].id
10let saves = {}
11let phase = 'idle'
12let isTurnRunning = false
13let isDismissed = false
14let timer = null
15let countdown = 0
16
17function boardProps() {
18  return {
19    gameId: currentGame,
20    saves,
21    isPaused: phase === 'countdown',
22    isClaudeWorking: isTurnRunning,
23    countdown: phase === 'countdown' ? countdown : null,
24  }
25}
26
27async function moveOldKeys($) {
28  for (const [oldKey, newKey] of Object.entries(OLD_KEYS)) {
29    const value = await $.store.get(oldKey)
30    if (value === undefined) continue
31    if ((await $.store.get(newKey)) === undefined) await $.store.set(newKey, value)
32    await $.store.delete(oldKey)
33  }
34}
35
36async function loadStore($) {
37  await moveOldKeys($)
38  isOn = (await $.store.get('isOn')) !== false
39  currentGame = (gameById(await $.store.get('lastGame')) ?? GAMES[0]).id
40  saves = {}
41  for (const { id } of GAMES) {
42    const best = await $.store.get(`best:${id}`)
43    saves[id] = { state: (await $.store.get(`game:${id}`)) ?? null, best: typeof best === 'number' ? best : 0 }
44  }
45}
46
47function cancelTimer() {
48  timer?.cancel()
49  timer = null
50}
51
52async function chooseGame($, id) {
53  currentGame = id
54  await $.store.set('lastGame', id)
55}
56
57function armDropIn($) {
58  if (!isOn || !isTurnRunning || isDismissed || phase !== 'idle') return
59  phase = 'waiting'
60  timer = $.clock.after(DROP_IN_DELAY_MS, () => void dropIn($))
61}
62
63async function dropIn($) {
64  if (phase !== 'waiting') return
65  timer = null
66  const opened = await $.ui.open({ id: PANE, title: PANE })
67  if (!opened.isPlaced) {
68    await $.ui.close({ id: PANE })
69    phase = 'offered'
70    $.ui.invalidate('ui.render')
71    return
72  }
73  phase = 'playing'
74  $.ui.invalidate('ui.render')
75}
76
77async function acceptOffer($) {
78  if (phase !== 'offered') return
79  const opened = await $.ui.open({ id: PANE, title: PANE })
80  if (!opened.isPlaced) {
81    await $.ui.close({ id: PANE })
82    return
83  }
84  phase = 'playing'
85  $.ui.invalidate('ui.render')
86}
87
88function withdrawOffer($) {
89  cancelTimer()
90  phase = 'idle'
91  $.ui.invalidate('ui.render')
92}
93
94async function pullOut($) {
95  cancelTimer()
96  phase = 'idle'
97  await $.ui.close({ id: PANE })
98}
99
100async function handBack($) {
101  if (phase === 'waiting' || phase === 'offered') withdrawOffer($)
102  else if (phase === 'playing' || phase === 'countdown') await pullOut($)
103}
104
105function startCountdown($) {
106  phase = 'countdown'
107  countdown = COUNTDOWN_SECONDS
108  $.ui.invalidate('ui.render')
109  timer = $.clock.every(1000, () => {
110    countdown -= 1
111    if (countdown > 0) $.ui.invalidate('ui.render')
112    else void pullOut($)
113  })
114}
115
116async function goAway($) {
117  cancelTimer()
118  phase = 'idle'
119  for (const [id, save] of Object.entries(saves)) {
120    if (save.state) await $.store.set(`game:${id}`, save.state)
121  }
122}
123
124/** @type {import('claude-code').Register} */
125export const register = (on) => {
126  on('session.start', async ($, e, next) => {
127    await loadStore($)
128    await $.command.register({
129      name: 'sidequest',
130      description: 'Play a game while Claude works',
131      argumentHint: '[on|off|<game>]',
132    })
133    return next(e)
134  })
135
136  on('command.run', { command: 'sidequest' }, async ($, e) => {
137    const arg = e.args.trim().toLowerCase()
138    if (arg === 'off') {
139      isOn = false
140      await $.store.set('isOn', false)
141      await handBack($)
142      return { text: 'Sidequest will not open by itself now. /sidequest on turns it back on.' }
143    }
144    if (arg === 'on') {
145      isOn = true
146      await $.store.set('isOn', true)
147      armDropIn($)
148      return { text: 'Sidequest opens by itself while Claude works.' }
149    }
150    if (arg) {
151      const game = gameById(arg)
152      if (!game) return { text: `No game named ${arg}. Games: ${GAMES.map(({ id }) => id).join(', ')}.` }
153      await chooseGame($, game.id)
154    }
155    cancelTimer()
156    phase = 'playing'
157    await $.ui.open({ id: PANE, title: PANE })
158    $.ui.invalidate('ui.render')
159    return {}
160  })
161
162  on('turn.start', async ($, e, next) => {
163    isTurnRunning = true
164    isDismissed = false
165    if (phase === 'countdown') {
166      cancelTimer()
167      phase = 'playing'
168    }
169    $.ui.invalidate('ui.render')
170    armDropIn($)
171    return next(e)
172  })
173
174  on('turn.complete', async ($, e, next) => {
175    if (e.agentId) return next(e)
176    isTurnRunning = false
177    if (phase === 'waiting' || phase === 'offered') withdrawOffer($)
178    else if (phase === 'playing') {
179      if (e.isAborted) await pullOut($)
180      else startCountdown($)
181    }
182    return next(e)
183  })
184
185  // In auto mode an ask can go to the classifier instead of the person; closing anyway costs a moment, missing a real prompt costs more
186  on('tool.check', async ($, e, next) => {
187    const result = await next(e)
188    if (e.tool_use_id && result.decision === 'ask') await handBack($)
189    return result
190  })
191
192  on('tool.call', async ($, e, next) => {
193    if (e.tool === 'AskUserQuestion') await handBack($)
194    const result = await next(e)
195    armDropIn($)
196    return result
197  })
198
199  on('ui.close', async ($, e, next) => {
200    if (e.id !== PANE) return next(e)
201    if (e.origin.kind === 'person' && isTurnRunning) isDismissed = true
202    await goAway($)
203    return next(e)
204  })
205
206  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
207    if (phase !== 'offered') return next(e)
208    const { Box, Button } = $.ui.resolve(e)
209    const others = await next(e)
210    return Box({
211      flexDirection: 'column',
212      children: [
213        Button({ key: 'play', label: `Play ${gameById(currentGame).title} while Claude works`, hotkey: '1', plain: true, onPress: () => acceptOffer($) }),
214        ...(others ? [others] : []),
215      ],
216    })
217  })
218
219  on('ui.message', async ($, e) => {
220    if (e.element !== 'board') return {}
221    if (gameById(e.data.lastGame)) {
222      await chooseGame($, e.data.lastGame)
223      $.ui.invalidate('ui.render')
224      return {}
225    }
226    const { gameId } = e.data
227    if (!gameById(gameId)) return {}
228    const old = saves[gameId]
229    const best = typeof e.data.best === 'number' ? Math.max(old.best, e.data.best) : old.best
230    saves = { ...saves, [gameId]: { state: e.data.state ?? null, best } }
231    if (best > old.best) await $.store.set(`best:${gameId}`, best)
232    return {}
233  })
234
235  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
236    if (e.requestId !== PANE) return next(e)
237    const { Box, Text, Client } = $.ui.resolve(e)
238    if (e.surface !== 'terminal') return Text({ children: ['Sidequest plays in the terminal.'] })
239    return Box({
240      children: [
241        Client({
242          key: 'board',
243          module: './frame.js',
244          props: boardProps(),
245          width: e.props.bodyColumns,
246          height: e.props.scroll.bodyRows,
247        }),
248      ],
249    })
250  })
251}
252
hooks/games/index.js 8 lines
1import snake from './snake/index.js'
2
3export const GAMES = [snake]
4
5export function gameById(id) {
6  return GAMES.find((game) => game.id === id)
7}
8
hooks/frame.js 173 lines
1import { GAMES, gameById } from './games/index.js'
2import { menuKey, menuLines, openMenu } from './menu.js'
3
4const FRAME_MS = 16
5const STATUS_AND_HINT_ROWS = 2
6
7export default function Frame(props, surface) {
8  let state = surface.state
9  if (state === undefined) {
10    const gameId = (gameById(props.gameId) ?? GAMES[0]).id
11    const saves = props.saves ?? {}
12    state = {
13      props,
14      requestedId: props.gameId,
15      gameId,
16      saves,
17      game: saves[gameId]?.state ?? null,
18      best: saves[gameId]?.best ?? 0,
19      size: null,
20      isPaused: true,
21      elapsedMs: 0,
22      menu: null,
23    }
24    surface.onKey((e) => onKey(surface, e.key))
25    surface.every(FRAME_MS, () => onFrame(surface))
26    surface.setState(state)
27  }
28  state.props = props
29  return draw(state, surface)
30}
31
32function playSize(surface) {
33  return { columns: surface.columns, rows: surface.rows - STATUS_AND_HINT_ROWS }
34}
35
36function isSameSize(a, b) {
37  return a !== null && a.columns === b.columns && a.rows === b.rows
38}
39
40function withGame(state, id) {
41  const saves = { ...state.saves, [state.gameId]: { state: state.game, best: state.best } }
42  return {
43    ...state,
44    saves,
45    gameId: id,
46    game: saves[id]?.state ?? null,
47    best: saves[id]?.best ?? 0,
48    size: null,
49    isPaused: true,
50    elapsedMs: 0,
51    menu: null,
52  }
53}
54
55function onFrame(surface) {
56  const state = surface.state
57  if (state.props.gameId !== state.requestedId) {
58    const target = gameById(state.props.gameId)
59    const next = target && target.id !== state.gameId ? withGame(state, target.id) : state
60    surface.setState({ ...next, requestedId: state.props.gameId })
61    return
62  }
63  const game = gameById(state.gameId)
64  const size = playSize(surface)
65  if (!game.fits(size)) return
66  if (!isSameSize(state.size, size)) {
67    const fitted = game.restore(state.game, size) ?? game.start(size)
68    surface.setState({ ...state, game: fitted, size, isPaused: true, elapsedMs: 0 })
69    return
70  }
71  if (state.props.isPaused && !state.isPaused) {
72    surface.setState({ ...state, isPaused: true })
73    return
74  }
75  if (state.isPaused || state.props.isPaused || game.isOver(state.game)) return
76  const stepMs = game.stepMs(state.game)
77  if (stepMs === null) return
78  state.elapsedMs += FRAME_MS
79  if (state.elapsedMs < stepMs) return
80  update(surface, { game: game.step(state.game), elapsedMs: state.elapsedMs - stepMs })
81}
82
83function onKey(surface, key) {
84  const state = surface.state
85  if (!state.game || state.size === null || state.props.isPaused) return
86  if (state.menu) {
87    onMenuKey(surface, key)
88    return
89  }
90  if (key.toLowerCase() === 'm' && GAMES.length > 1) {
91    surface.setState({ ...state, menu: openMenu(GAMES.map(({ id }) => id), state.gameId), isPaused: true })
92    return
93  }
94  const game = gameById(state.gameId)
95  if (key === ' ' || key === 'space') {
96    if (game.isOver(state.game)) update(surface, { game: game.start(state.size), isPaused: false, elapsedMs: 0 })
97    else update(surface, { isPaused: !state.isPaused })
98    return
99  }
100  if (game.isOver(state.game)) return
101  const next = game.key(state.game, key)
102  if (next !== null) update(surface, { game: next, isPaused: false })
103}
104
105function onMenuKey(surface, key) {
106  const state = surface.state
107  const result = menuKey(state.menu, key)
108  if (result.action === 'move') surface.setState({ ...state, menu: result.menu })
109  else if (result.action === 'close') surface.setState({ ...state, menu: null })
110  else if (result.action === 'pick') {
111    surface.setState(withGame(state, result.id))
112    surface.post({ lastGame: result.id })
113  }
114}
115
116function update(surface, changes) {
117  const state = { ...surface.state, ...changes }
118  state.best = Math.max(state.best, gameById(state.gameId).score(state.game))
119  surface.setState(state)
120  surface.post({ gameId: state.gameId, state: state.game, best: state.best })
121}
122
123function draw(state, surface) {
124  const { Box, Text } = surface.elements
125  const game = gameById(state.gameId)
126  const size = playSize(surface)
127  if (surface.columns > 0 && !game.fits(size)) {
128    return Text({ dimColor: true, children: ['Make the pane bigger to play.'] })
129  }
130  if (!state.game || !isSameSize(state.size, size)) {
131    return Text({ dimColor: true, children: ['…'] })
132  }
133  return Box({
134    flexDirection: 'column',
135    children: [
136      Text({ children: [statusText(state, game)] }),
137      state.menu ? menuBox(state, Box, Text) : game.draw(state.game, surface.elements),
138      Text({ dimColor: true, children: [hintText(state, game)] }),
139    ],
140  })
141}
142
143function menuBox(state, Box, Text) {
144  const describe = (id) => ({
145    title: gameById(id).title,
146    best: id === state.gameId ? state.best : (state.saves[id]?.best ?? 0),
147  })
148  return Box({
149    flexDirection: 'column',
150    children: menuLines(state.menu, state.gameId, describe).map((line) => Text({ children: [line] })),
151  })
152}
153
154function statusText(state, game) {
155  const { props } = state
156  const claude =
157    props.countdown !== null && props.countdown !== undefined
158      ? `Claude is done · back in ${props.countdown}`
159      : props.isClaudeWorking
160        ? '● Claude is working'
161        : '✓ Claude is done'
162  const score = game.score(state.game)
163  return `${game.title} · Score ${score} · Best ${Math.max(state.best, score)} · ${claude}`
164}
165
166function hintText(state, game) {
167  if (state.props.isPaused) return 'Paused while Claude hands you back'
168  if (state.menu) return '↑↓ choose · Enter play · m back'
169  if (game.isOver(state.game)) return 'Game over · Space to play again'
170  if (state.isPaused) return 'Paused · click the board, then press a key to play'
171  return `${game.hint} · Space pauses${GAMES.length > 1 ? ' · m games' : ''}`
172}
173
hooks/games/snake/index.js 89 lines
1import { MIN_HEIGHT, MIN_WIDTH, fitTo, newGame, step, tickMs, turn } from './rules.js'
2
3const WALL_COLUMNS = 2
4const WALL_ROWS = 2
5const SNAKE_COLOR = '#4ade80'
6const HEAD_COLOR = '#d9f99d'
7const FOOD_COLOR = '#f87171'
8const WALL_COLOR = 'gray'
9const KEY_DIRECTIONS = { up: 'up', down: 'down', left: 'left', right: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }
10
11function cells({ columns, rows }) {
12  return { width: Math.floor((columns - WALL_COLUMNS) / 2), height: rows - WALL_ROWS }
13}
14
15function fits(size) {
16  const { width, height } = cells(size)
17  return width >= MIN_WIDTH && height >= MIN_HEIGHT
18}
19
20function start(size) {
21  const { width, height } = cells(size)
22  return newGame(width, height)
23}
24
25function restore(saved, size) {
26  const { width, height } = cells(size)
27  return fitTo(saved, width, height)
28}
29
30function key(game, pressed) {
31  const direction = KEY_DIRECTIONS[pressed.length === 1 ? pressed.toLowerCase() : pressed]
32  return direction ? turn(game, direction) : null
33}
34
35function draw(game, { Box, Text }) {
36  return Box({
37    flexDirection: 'column',
38    children: [
39      Text({ color: WALL_COLOR, children: ['┌' + '──'.repeat(game.width) + '┐'] }),
40      ...boardRows(game, Text),
41      Text({ color: WALL_COLOR, children: ['└' + '──'.repeat(game.width) + '┘'] }),
42    ],
43  })
44}
45
46function boardRows(game, Text) {
47  const colors = new Map(game.snake.map(([x, y]) => [y * game.width + x, SNAKE_COLOR]))
48  const [headX, headY] = game.snake[0]
49  colors.set(headY * game.width + headX, HEAD_COLOR)
50  if (game.food) colors.set(game.food[1] * game.width + game.food[0], FOOD_COLOR)
51  const rows = []
52  for (let y = 0; y < game.height; y++) {
53    const runs = []
54    for (let x = 0; x < game.width; x++) {
55      const color = colors.get(y * game.width + x) ?? null
56      const last = runs.at(-1)
57      if (last && last.color === color) last.count += 1
58      else runs.push({ color, count: 1 })
59    }
60    rows.push(
61      Text({
62        children: [
63          Text({ color: WALL_COLOR, children: ['│'] }),
64          ...runs.map(({ color, count }) =>
65            color ? Text({ color, children: ['██'.repeat(count)] }) : '  '.repeat(count),
66          ),
67          Text({ color: WALL_COLOR, children: ['│'] }),
68        ],
69      }),
70    )
71  }
72  return rows
73}
74
75export default {
76  id: 'snake',
77  title: 'Snake',
78  hint: 'Arrows or WASD turn',
79  fits,
80  start,
81  restore,
82  key,
83  stepMs: tickMs,
84  step: (game) => step(game),
85  score: (game) => game.score,
86  isOver: (game) => game.isOver,
87  draw,
88}
89
hooks/menu.js 26 lines
1const UP_KEYS = new Set(['up', 'w'])
2const DOWN_KEYS = new Set(['down', 's'])
3const PICK_KEYS = new Set(['return', ' ', 'space'])
4
5export function openMenu(ids, currentId) {
6  return { ids, index: Math.max(0, ids.indexOf(currentId)) }
7}
8
9export function menuKey(menu, key) {
10  const pressed = key.length === 1 ? key.toLowerCase() : key
11  if (pressed === 'm') return { action: 'close' }
12  if (PICK_KEYS.has(pressed)) return { action: 'pick', id: menu.ids[menu.index] }
13  const delta = UP_KEYS.has(pressed) ? -1 : DOWN_KEYS.has(pressed) ? 1 : 0
14  if (delta === 0) return { action: 'none' }
15  const count = menu.ids.length
16  return { action: 'move', menu: { ...menu, index: (menu.index + delta + count) % count } }
17}
18
19export function menuLines(menu, currentId, describe) {
20  return menu.ids.map((id, i) => {
21    const { title, best } = describe(id)
22    const marker = i === menu.index ? '›' : ' '
23    return `${marker} ${title} · Best ${best}${id === currentId ? ' · playing' : ''}`
24  })
25}
26
hooks/games/snake/rules.js 91 lines
1export const MIN_WIDTH = 8
2export const MIN_HEIGHT = 4
3
4const MOVES = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }
5const OPPOSITE = { up: 'down', down: 'up', left: 'right', right: 'left' }
6const START_TICK_MS = 115
7const FASTEST_TICK_MS = 40
8const FOODS_PER_SPEEDUP = 5
9const SPEEDUP_MS = 7
10const MAX_QUEUED_TURNS = 2
11
12export function newGame(width, height, random = Math.random) {
13  const y = Math.floor(height / 2)
14  const x = Math.floor(width / 3) + 2
15  const snake = [[x, y], [x - 1, y], [x - 2, y]]
16  return {
17    width,
18    height,
19    snake,
20    direction: 'right',
21    queued: [],
22    food: placeFood(width, height, snake, random),
23    score: 0,
24    isOver: false,
25  }
26}
27
28export function turn(game, direction) {
29  if (game.isOver || !MOVES[direction]) return game
30  const last = game.queued.at(-1) ?? game.direction
31  if (direction === last || direction === OPPOSITE[last] || game.queued.length >= MAX_QUEUED_TURNS) return game
32  return { ...game, queued: [...game.queued, direction] }
33}
34
35export function step(game, random = Math.random) {
36  if (game.isOver) return game
37  const [direction = game.direction, ...queued] = game.queued
38  const [dx, dy] = MOVES[direction]
39  const head = [game.snake[0][0] + dx, game.snake[0][1] + dy]
40  const eats = game.food !== null && head[0] === game.food[0] && head[1] === game.food[1]
41  const body = eats ? game.snake : game.snake.slice(0, -1)
42  const isOutside = head[0] < 0 || head[1] < 0 || head[0] >= game.width || head[1] >= game.height
43  if (isOutside || body.some(([x, y]) => x === head[0] && y === head[1])) {
44    return { ...game, direction, queued, isOver: true }
45  }
46  const snake = [head, ...body]
47  if (!eats) return { ...game, snake, direction, queued }
48  const food = placeFood(game.width, game.height, snake, random)
49  return { ...game, snake, direction, queued, score: game.score + 1, food, isOver: food === null }
50}
51
52export function tickMs(game) {
53  return Math.max(FASTEST_TICK_MS, START_TICK_MS - Math.floor(game.score / FOODS_PER_SPEEDUP) * SPEEDUP_MS)
54}
55
56export function fitTo(value, width, height) {
57  if (!isGame(value)) return null
58  const isInside = ([x, y]) => x < width && y < height
59  if (!value.snake.every(isInside) || (value.food !== null && !isInside(value.food))) return null
60  return { ...value, width, height }
61}
62
63function placeFood(width, height, snake, random) {
64  const taken = new Set(snake.map(([x, y]) => y * width + x))
65  const free = []
66  for (let i = 0; i < width * height; i++) if (!taken.has(i)) free.push(i)
67  if (free.length === 0) return null
68  const cell = free[Math.floor(random() * free.length)]
69  return [cell % width, Math.floor(cell / width)]
70}
71
72function isGame(value) {
73  return (
74    typeof value === 'object' &&
75    value !== null &&
76    Array.isArray(value.snake) &&
77    value.snake.length > 0 &&
78    value.snake.every(isCell) &&
79    (value.food === null || isCell(value.food)) &&
80    MOVES[value.direction] !== undefined &&
81    Array.isArray(value.queued) &&
82    value.queued.every((direction) => MOVES[direction] !== undefined) &&
83    Number.isInteger(value.score) &&
84    typeof value.isOver === 'boolean'
85  )
86}
87
88function isCell(value) {
89  return Array.isArray(value) && value.length === 2 && value.every((n) => Number.isInteger(n) && n >= 0)
90}
91