house.wad in a Claude Code pane: your Home Assistant home as a Doom level, played in the terminal. Shoot a lamp and it turns off for real.

Your Home Assistant home as a Doom level, played in the terminal beside Claude. The rooms are your areas, and lamps, plugs and screens are where they really are. Shoot a lamp and it turns off for real. Use a door and its lock opens, after a Y/N. House problems are the monsters: a lamp left burning, a plug on standby, a window open while the heating runs.
It is house.wad, the Lovelace card, run by Node instead of a browser and drawn in coloured half blocks.
1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.
Open Vome and create a key on its Agent tab.
2. In Claude Code, in a terminal:
/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp
/plugin install vome-doom --marketplace Vortitron/home-assistant-mcp
/reload-plugins
3. Play: /doom, then Practice (nothing in the house changes) or Play for real. /doom practice and /doom play start one straight away. Click the picture to give the pane the keyboard.
4. On your dashboard too: the pane's Put it on my Home Assistant dashboard button (or /doom install) asks Claude to add house.wad's card to your Home Assistant, through HACS if you have it, so the same game plays in a browser. Claude asks which dashboard first and changes nothing else.
It needs Node 18 or newer on the computer. The first game downloads its data once, about 19 MB (house.wad's engine and Freedoom's free game data), into ~/.cache/vome-doom.
Click the picture and these work, as long as the pane has the keyboard:
| W / S | forward, back |
| A / D | turn left, right |
| Z / X | strafe left, right |
| F | fire |
| E | use (doors, switches) |
| Y / N | answer the door's question |
| M | the menu |
| 1 to 7 | weapons |
For the full keyboard (arrows, Space to fire), click the line under the picture instead. Esc gives the keyboard back to Claude Code. A terminal reports key presses but not releases, so a press holds the key for a moment, and holding it down keeps you moving. The pane counts the keys it has sent, which shows at a glance whether it is hearing you.
Starting a game reads the home in the background: ha_list_areas, ha_list_devices, ha_get_entity_registry, ha_list_entities and ha_get_state, all read-only. In auto mode Claude Code refuses background calls nobody asked for unless they are allowed by name; when it does, the pane shows the exact lines for your Vome server, a link to your settings.json, a Copy button and Retry. With vome-connect they are:
"mcp__plugin_vome-connect_vome__ha_list_areas",
"mcp__plugin_vome-connect_vome__ha_list_devices",
"mcp__plugin_vome-connect_vome__ha_get_entity_registry",
"mcp__plugin_vome-connect_vome__ha_list_entities",
"mcp__plugin_vome-connect_vome__ha_get_state"
Playing for real calls ha_call_service when you shoot a lamp or open a door. Allowing that by name would let Claude change things without asking too, so the pane leaves that choice to you: practise, play with auto mode off, or allow it if you are happy to.
What house.wad's card can, by its own rules: lights, switches, media players and vacuums. Switches whose names look important (a NAS, a router, a server rack) are left alone, and a lock or door asks Y/N first. Practice changes nothing.
house.wad is GPL-2.0-or-later, built on doomgeneric. Freedoom is BSD-licensed. This plugin downloads them; it does not include them.
hooks/register.tsx 541 lines1// house.wad in a pane: the Home Assistant home as a Doom level, in the terminal.
2//
3// house.wad's own game (github.com/Vortitron/housewad) runs as a Node process:
4// its terminal host builds the level from a snapshot of the home, runs the
5// same engine as the Lovelace card, and sends frames as half-block cells,
6// which this pane draws in a Raster. Keys come from a Client strip under the
7// screen and reach the game as small files in a folder (a process started
8// from a plugin has no open stdin). What the game does to the house (a lamp
9// shot, a door used) arrives as a call, which this pane makes through the
10// Vome MCP, as the dashboard pane's buttons do.
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { Phase, Status } from '../types'
15
16const PANE = 'house-wad'
17/** The house.wad release whose dist/ has the terminal host. */
18const HOUSEWAD_VERSION = '0.8.1'
19const RELEASE = `https://raw.githubusercontent.com/Vortitron/housewad/v${HOUSEWAD_VERSION}/dist/`
20const FILES = ['housewad-term.mjs', 'housewad-engine.js', 'housewad-engine.wasm', 'housewad-zdbsp.js', 'housewad-zdbsp.wasm', 'freedoom2.wad']
21/** What the level is built from: the domains house.wad reads. */
22/** The reads a game starts with, all read-only: what auto mode needs allowed by name. */
23const READS = ['ha_list_areas', 'ha_list_devices', 'ha_get_entity_registry', 'ha_list_entities', 'ha_get_state']
24const DOMAINS = ['light', 'switch', 'lock', 'vacuum', 'sensor', 'media_player', 'cover', 'person', 'sun', 'camera', 'binary_sensor', 'water_heater', 'climate', 'alarm_control_panel']
25
26const phase = atom({ plugin: 'vome-doom', key: 'phase' } as const, 'idle' as Phase)
27const note = atom({ plugin: 'vome-doom', key: 'note' } as const, null as string | null)
28const size = atom({ plugin: 'vome-doom', key: 'size' } as const, null as { columns: number; rows: number } | null)
29const status = atom({ plugin: 'vome-doom', key: 'status' } as const, null as Status | null)
30const homeName = atom({ plugin: 'vome-doom', key: 'home' } as const, null as string | null)
31const blocked = atom({ plugin: 'vome-doom', key: 'blocked' } as const, null as { server: string; tool: string } | null)
32
33// The running game: the module's own, gone with a reload (which kills the child too).
34let frame: string | null = null
35let inputDir: string | null = null
36let sequence = 0
37let server: string | null = null
38let room = { columns: 96, rows: 36 }
39let stopRequested = false
40let settingsFile = '~/.claude/settings.json'
41let lastMode: 'real' | 'practice' = 'practice'
42let keysSent = 0
43/** The size last asked of the game, and when: a resize asks again, twice a second at most. */
44let askedSize = { columns: 0, rows: 0 }
45let askedAt = 0
46let isRunning = false
47
48/**
49 * The controls as pane hotkeys: they work once the pane has the keyboard, which any click on it
50 * gives (the picture included), unlike the strip, which must be clicked itself. Hotkeys are
51 * letters and digits only, so arrows and Space stay with the strip. Each sends the game the
52 * terminal key name or character its own keys map from (A and D in the game strafe; here they turn).
53 */
54const CONTROLS: Array<{ hotkey: string; label: string; key: string }> = [
55 { hotkey: 'w', label: 'forward', key: 'up' },
56 { hotkey: 's', label: 'back', key: 'down' },
57 { hotkey: 'a', label: 'turn left', key: 'left' },
58 { hotkey: 'd', label: 'turn right', key: 'right' },
59 { hotkey: 'z', label: 'strafe left', key: 'a' },
60 { hotkey: 'x', label: 'strafe right', key: 'd' },
61 { hotkey: 'f', label: 'fire', key: 'f' },
62 { hotkey: 'e', label: 'use', key: 'e' },
63 { hotkey: 'y', label: 'yes', key: 'y' },
64 { hotkey: 'n', label: 'no', key: 'n' },
65 { hotkey: 'm', label: 'menu', key: 'm' },
66 ...['fist', 'pistol', 'shotgun', 'chaingun', 'rockets', 'plasma', 'BFG'].map((label, i) => ({ hotkey: String(i + 1), label, key: String(i + 1) })),
67]
68
69export const register: Register = on => {
70 on('session.start', async ($, e, next) => {
71 await $.command.register({ name: 'doom', description: 'Play house.wad: your Home Assistant home as a Doom level, in a pane (play, practice)' })
72 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
73 settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
74 return next(e)
75 })
76
77 // /doom opens the pane; /doom play or /doom practice starts a game straight away.
78 on('command.run', { command: 'doom' }, async ($, e) => {
79 await $.ui.open({ id: PANE, title: 'house.wad', focus: true })
80 const word = e.args.trim().toLowerCase()
81 if (word === 'install') {
82 await $.prompt.submit({ text: installPrompt((await read($, homeName)) ?? null) })
83 return { text: 'Asking Claude to put house.wad on your Home Assistant dashboard.' }
84 }
85 if (word === 'play' || word === 'practice') {
86 void start($, word === 'play' ? 'real' : 'practice')
87 return { text: `Starting house.wad${word === 'practice' ? ' in practice (nothing in the house changes)' : ''}. Click the strip under the screen to play.` }
88 }
89 return { text: 'house.wad is open in the pane. Press Play when you are ready (or /doom play, /doom practice, /doom install).' }
90 })
91
92 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
93 if (e.surface !== 'terminal') {
94 const { Text } = $.ui.resolve(e)
95 return <Text>house.wad plays in the terminal.</Text>
96 }
97 const { Box, Text, Button, Raster, Client, Markdown, Code } = $.ui.resolve(e)
98 // Room for the screen: the pane's whole width, its height less the strip, the controls and Stop.
99 const bodyRows = e.props.scroll?.bodyRows ?? (e.viewport?.rows ?? 40) - 2
100 room = { columns: Math.max(40, e.props.bodyColumns), rows: Math.max(12, bodyRows - 6) }
101 if (isRunning && (room.columns !== askedSize.columns || Math.abs(room.rows - askedSize.rows) >= 2) && Date.now() - askedAt > 500) {
102 askedSize = { ...room }
103 askedAt = Date.now()
104 void send($, [{ t: 'size', columns: room.columns, rows: room.rows }])
105 }
106 const now = await read($, phase)
107 const said = await read($, note)
108 const home = await read($, homeName)
109 const refusal = await read($, blocked)
110 const rules = refusal
111 ? refusal.tool === 'ha_call_service'
112 ? `"mcp__${refusal.server}__ha_call_service"`
113 : READS.map(tool => `"mcp__${refusal.server}__${tool}"`).join(',\n')
114 : ''
115 const help = refusal ? (
116 <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1}>
117 {refusal.tool === 'ha_call_service' ? (
118 <Text color="warning" wrap="wrap">
119 Auto mode refused what the game did to the house: it calls ha_call_service, and allowing that by name would let
120 Claude change things without asking too. Practise, switch auto mode off, or allow it in {settingsFile} if you are
121 happy with that:
122 </Text>
123 ) : (
124 <Text color="warning" wrap="wrap">
125 Auto mode refused reading your home. Add these to permissions.allow in {settingsFile}; they only read:
126 </Text>
127 )}
128 <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
129 <Code source={rules} />
130 <Box flexDirection="row" gap={2}>
131 <Button key="copy-rule" hotkey="c" onPress={press => $.ui.copy({ text: rules, surface: press.surface })}>
132 Copy
133 </Button>
134 {refusal.tool === 'ha_call_service' ? null : (
135 <Button
136 key="retry"
137 hotkey="r"
138 onPress={async () => {
139 await update($, blocked, () => null)
140 await start($, lastMode)
141 }}
142 >
143 Retry
144 </Button>
145 )}
146 <Button key="dismiss-rule" dimColor onPress={() => update($, blocked, () => null)}>
147 Dismiss
148 </Button>
149 </Box>
150 </Box>
151 ) : null
152
153 if (now === 'playing') {
154 const shape = await read($, size)
155 const line = await read($, status)
156 if (!shape) return <Text dimColor>Starting…</Text>
157 const text = line
158 ? [line.room, line.aim ? `aiming at ${line.aim}` : '', line.last].filter(Boolean).join(' · ') || 'LIVE: this is your house'
159 : 'LIVE: this is your house'
160 return (
161 <Box flexDirection="column">
162 <Raster key="screen" columns={shape.columns} rows={shape.rows} cells={frame ?? blank(shape.columns, shape.rows)} />
163 <Client
164 key="keys"
165 module="./keys.tsx"
166 width={shape.columns}
167 props={{ text, hint: 'Click this line for the full keyboard: arrows, Space to fire. Esc gives the keys back.' }}
168 />
169 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
170 {CONTROLS.map(c => (
171 <Button key={`k-${c.hotkey}`} plain hotkey={c.hotkey} onPress={() => send($, [{ t: 'key', key: c.key }])}>
172 {c.label}
173 </Button>
174 ))}
175 </Box>
176 <Box flexDirection="row" gap={1}>
177 <Button key="stop" onPress={() => stop($)}>Stop</Button>
178 <Text dimColor wrap="truncate-end">
179 {said ? `${said} · ` : ''}Click the picture, then the keys above work. Keys sent: {keysSent}
180 </Text>
181 </Box>
182 {help}
183 </Box>
184 )
185 }
186
187 const busy = now === 'checking' || now === 'downloading' || now === 'reading' || now === 'building'
188 return (
189 <Box flexDirection="column" gap={1}>
190 <Text bold>house.wad{home ? `: ${home}` : ''}</Text>
191 <Text wrap="wrap">
192 Your home as a Doom level. Rooms are your areas; lamps, plugs and screens are where they really are. Shoot a lamp and it
193 turns off for real. Use a door and its lock opens, after a Y/N. House problems are the monsters.
194 </Text>
195 {busy ? <Text color="yellow">{said ?? 'Working…'}</Text> : said && !refusal ? <Text color={now === 'failed' ? 'red' : undefined} wrap="wrap">{said}</Text> : null}
196 {help}
197 {busy ? null : (
198 <Box flexDirection="row" gap={1} flexWrap="wrap">
199 <Button key="play" variant="primary" hotkey="p" onPress={() => start($, 'real')}>Play for real</Button>
200 <Button key="practice" hotkey="t" onPress={() => start($, 'practice')}>Practice (nothing changes)</Button>
201 <Button key="install" hotkey="i" onPress={() => $.prompt.submit({ text: installPrompt(home) })}>
202 Put it on my Home Assistant dashboard
203 </Button>
204 </Box>
205 )}
206 <Text dimColor wrap="wrap">
207 Needs Node 18 or newer. The first game downloads its data once (about 19 MB, Freedoom's free game data). Built from
208 github.com/Vortitron/housewad. The dashboard button asks Claude to add house.wad's card to your Home Assistant, so
209 you can play it in a browser too.
210 </Text>
211 <Text dimColor wrap="wrap">
212 Played through Vome, which also gives Home Assistant remote access without port forwarding, a health score and keys
213 for coding agents: vome.io
214 </Text>
215 </Box>
216 )
217 })
218
219 // Keys from the strip under the screen, to the game.
220 on('ui.message', async ($, e, next) => {
221 if (e.requestId !== PANE || e.element !== 'keys') return next(e)
222 const data = e.data as { keys?: unknown }
223 const keys = Array.isArray(data?.keys) ? data.keys.filter((k): k is string => typeof k === 'string' && k.length > 0) : []
224 if (keys.length > 0) await send($, keys.map(key => ({ t: 'key', key })))
225 return {}
226 })
227}
228
229// ---------------------------------------------------------------- the game
230
231async function start($: EngineInterface, mode: 'real' | 'practice') {
232 lastMode = mode
233 stopRequested = false
234 await update($, blocked, () => null)
235 frame = null
236 await update($, status, () => null)
237 await update($, size, () => null)
238 try {
239 await setPhase($, 'checking', 'Looking for Node…')
240 let why = ''
241 const node = await $.process.run(['node', '--version'], { timeoutMs: 10_000 }).catch(error => {
242 why = firstLine(String(error)) ?? ''
243 return null
244 })
245 const found = node && node.exitCode === 0 ? node.stdout.trim() : ''
246 const major = Number(/^v(\d+)/.exec(found)?.[1] ?? 0)
247 if (major < 18) {
248 return fail($, `house.wad needs Node 18 or newer on this computer (nodejs.org), then press Play again.${found ? ` This one is ${found}.` : why ? ` (${why})` : ''}`)
249 }
250
251 const home = await $.env.get('HOME')
252 if (!home) return fail($, 'There is no HOME folder to keep the game data in.')
253 const dir = `${home}/.cache/vome-doom/${HOUSEWAD_VERSION}`
254 const missing: string[] = []
255 for (const file of FILES) if (!(await $.fs.exists(`${dir}/${file}`))) missing.push(file)
256 if (missing.length > 0 && !(await download($, dir, missing))) return
257
258 server = await findServer($)
259 if (!server) return fail($, 'Connect Home Assistant through Vome first: /plugin install vome-connect --marketplace Vortitron/home-assistant-mcp')
260 await setPhase($, 'reading', 'Reading your home…')
261 const snapshot = await readHome($, server)
262 if (!snapshot) return
263 await update($, homeName, () => snapshot.location_name)
264
265 await setPhase($, 'building', `Building ${snapshot.location_name} as a level…`)
266 inputDir = `${home}/.cache/vome-doom/run-${Date.now()}`
267 await $.fs.write(`${inputDir}/.keep`, '')
268 void run($, dir, mode, snapshot)
269 } catch (error) {
270 await fail($, `Could not start: ${String(error)}`)
271 }
272}
273
274async function run($: EngineInterface, dir: string, mode: 'real' | 'practice', snapshot: Snapshot) {
275 const argv = [
276 'node', `${dir}/housewad-term.mjs`, '--assets', dir, '--input', inputDir!, '--mode', mode,
277 '--columns', String(room.columns), '--rows', String(room.rows),
278 ]
279 let pending = ''
280 let isPlaying = false
281 isRunning = true
282 askedSize = { ...room }
283 askedAt = Date.now()
284 try {
285 for await (const piece of $.process.spawn({ argv, input: JSON.stringify(snapshot) })) {
286 if (stopRequested) break
287 if (piece.stream !== 'stdout') continue
288 pending += piece.text
289 let at = pending.indexOf('\n')
290 while (at >= 0) {
291 const line = pending.slice(0, at)
292 pending = pending.slice(at + 1)
293 at = pending.indexOf('\n')
294 let message: Record<string, unknown>
295 try {
296 message = JSON.parse(line) as Record<string, unknown>
297 } catch {
298 continue
299 }
300 if (message.t === 'frame' && typeof message.cells === 'string') {
301 frame = message.cells
302 const shape = (await read($, size))
303 if (isPlaying && shape) void $.ui.blit({ requestId: PANE, key: 'screen', cells: frame, columns: shape.columns, rows: shape.rows })
304 } else if (message.t === 'ready') {
305 // The first says the game is on; later ones are a new size after the pane changed.
306 frame = null
307 await update($, size, () => ({ columns: Number(message.columns), rows: Number(message.rows) }))
308 if (!isPlaying) await setPhase($, 'playing', mode === 'real' ? null : 'Practice: nothing in the house changes.')
309 isPlaying = true
310 } else if (message.t === 'status') {
311 await update($, status, () => ({ room: String(message.room ?? ''), aim: String(message.aim ?? ''), last: String(message.last ?? ''), world: String(message.world ?? '') }))
312 } else if (message.t === 'call') {
313 void act($, message)
314 } else if (message.t === 'error') {
315 isRunning = false
316 await fail($, String(message.text ?? 'The game stopped.'))
317 return
318 }
319 }
320 }
321 } catch (error) {
322 isRunning = false
323 await fail($, `The game stopped: ${String(error)}`)
324 return
325 }
326 isRunning = false
327 if ((await read($, phase)) !== 'failed') await setPhase($, 'ended', stopRequested ? null : 'The game ended.')
328}
329
330/** A call the game makes on the house: through the MCP, then the entity's new state back to it. */
331async function act($: EngineInterface, message: Record<string, unknown>) {
332 const id = message.id
333 const data = (message.data && typeof message.data === 'object' ? message.data : {}) as Record<string, unknown>
334 let error: string | null = null
335 try {
336 const result = await $.mcp.call(server!, 'ha_call_service', { domain: message.domain, service: message.service, data })
337 if (result.isError) error = firstLine(result.content.map(block => block.text ?? '').join('\n')) ?? 'refused'
338 } catch (thrown) {
339 if (/auto mode|classifier|permission|denied/i.test(String(thrown))) {
340 error = 'auto mode refused it'
341 await update($, blocked, () => ({ server: server!, tool: 'ha_call_service' }))
342 } else {
343 error = firstLine(String(thrown)) ?? 'failed'
344 }
345 }
346 await send($, [{ t: 'reply', id, error }])
347 const entity = typeof data.entity_id === 'string' ? data.entity_id : null
348 if (!entity || error) return
349 // The device answers in its own time: look twice.
350 for (const wait of [700, 2500]) {
351 await $.clock.sleep(wait)
352 const state = await stateOf($, entity)
353 if (state) await send($, [{ t: 'state', entity_id: entity, state }])
354 }
355}
356
357async function stop($: EngineInterface) {
358 stopRequested = true
359 await send($, [{ t: 'quit' }])
360 await setPhase($, 'ended', null)
361}
362
363/** Messages to the game: one small file each batch, read in name order and deleted by the game. */
364async function send($: EngineInterface, messages: unknown[]) {
365 if (!inputDir) return
366 keysSent += messages.filter(m => (m as { t?: string }).t === 'key').length
367 sequence += 1
368 await $.fs.write(`${inputDir}/${String(sequence).padStart(9, '0')}.json`, messages.map(m => JSON.stringify(m)).join('\n') + '\n')
369 // The pane shows how many keys went out: a redraw, folded to ten a second by the engine.
370 if (messages.some(m => (m as { t?: string }).t === 'key')) $.ui.invalidate('ui.render')
371}
372
373async function download($: EngineInterface, dir: string, files: string[]): Promise<boolean> {
374 await setPhase($, 'downloading', 'Downloading the game data (once)…')
375 // Node fetches, so nothing but Node is needed; each file lands whole or not at all.
376 const script = [
377 "const fs=require('fs'),path=require('path');",
378 'const [dir,base,...files]=process.argv.slice(1);',
379 '(async()=>{fs.mkdirSync(dir,{recursive:true});',
380 'for(const f of files){const r=await fetch(base+f);if(!r.ok)throw new Error(f+": HTTP "+r.status);',
381 "const total=Number(r.headers.get('content-length'))||0;const parts=[];let got=0,n=0;",
382 'for await(const c of r.body){parts.push(c);got+=c.length;if(++n%200===0)console.log(JSON.stringify({file:f,got,total}))}',
383 "fs.writeFileSync(path.join(dir,f+'.part'),Buffer.concat(parts));fs.renameSync(path.join(dir,f+'.part'),path.join(dir,f));",
384 'console.log(JSON.stringify({file:f,done:true}))}})().catch(e=>{console.error(e.message);process.exit(1)})',
385 ].join('')
386 let errors = ''
387 try {
388 const fetching = $.process.spawn({ argv: ['node', '-e', script, dir, RELEASE, ...files] })
389 for await (const piece of fetching) {
390 if (piece.stream === 'stderr') {
391 errors += piece.text
392 continue
393 }
394 for (const line of piece.text.split('\n')) {
395 try {
396 const p = JSON.parse(line) as { file?: string; got?: number; total?: number }
397 const total = p.total ?? 0
398 if (p.file && total > 0) await update($, note, () => `Downloading ${p.file}: ${Math.round((p.got ?? 0) / 1e6)} of ${Math.round(total / 1e6)} MB`)
399 } catch {
400 // a partial line
401 }
402 }
403 }
404 } catch (error) {
405 errors += String(error)
406 }
407 for (const file of files) {
408 if (!(await $.fs.exists(`${dir}/${file}`))) {
409 await fail($, `Could not download the game data (${firstLine(errors) ?? file}). Check the connection and press Play again.`)
410 return false
411 }
412 }
413 return true
414}
415
416/** What the dashboard button asks Claude: house.wad's own install steps, done through the Vome MCP. */
417function installPrompt(home: string | null): string {
418 return [
419 `Put the house.wad card on my Home Assistant dashboard${home ? ` (${home})` : ''}, using the Vome MCP.`,
420 '',
421 '1. First check it is not there already: a Lovelace resource or a card of type custom:housewad-card.',
422 '2. If HACS is installed, add https://github.com/Vortitron/housewad to HACS as a custom repository of the Dashboard',
423 ' (plugin) category, download it, and check its housewad-card.js is registered as a JavaScript module resource.',
424 '3. Without HACS, do not copy files into /config/www: tell me the manual steps from the house.wad README instead.',
425 '4. Then ask me which dashboard should get it, offering a new one called house.wad, and add a panel view with',
426 ' `type: custom:housewad-card` there. Change nothing else.',
427 '',
428 'house.wad turns the house into a Doom level: in its own Play mode, shooting a lamp turns it off for real, and a',
429 'lock asks Y/N before it opens. Its card has a Practice mode too.',
430 ].join('\n')
431}
432
433// ---------------------------------------------------------------- the home
434
435type Snapshot = {
436 location_name: string
437 areas: unknown[]
438 devices: unknown[]
439 entities: unknown[]
440 states: unknown[]
441}
442
443async function findServer($: EngineInterface): Promise<string | null> {
444 const names = (await $.tool.list()).map(tool => /^mcp__(.+)__ha_list_areas$/.exec(tool.name)?.[1]).filter((s): s is string => !!s)
445 return names.find(name => /vome/i.test(name)) ?? names[0] ?? null
446}
447
448async function readHome($: EngineInterface, from: string): Promise<Snapshot | null> {
449 let location: string | null = null
450 const ask = async (tool: string, args: Record<string, unknown>) => {
451 try {
452 const result = await $.mcp.call(from, tool, args)
453 const text = result.content.map(block => block.text ?? '').join('\n')
454 location = location ?? /\[vome-instance\][^\n]*home="([^"]+)"/.exec(text)?.[1] ?? null
455 if (result.isError) throw new Error(firstLine(text) ?? `${tool} failed`)
456 return parseJson(text) ?? {}
457 } catch (error) {
458 if (/auto mode|classifier|permission|denied/i.test(String(error))) {
459 await update($, blocked, () => ({ server: from, tool }))
460 throw new Error(`auto mode refused ${tool}.`)
461 }
462 throw error
463 }
464 }
465 try {
466 const areas = ((await ask('ha_list_areas', {})).areas as unknown[]) ?? []
467 const devices = ((await ask('ha_list_devices', {})).devices as unknown[]) ?? []
468 const entities: unknown[] = []
469 const states: unknown[] = []
470 for (const domain of DOMAINS) {
471 await update($, note, () => `Reading your home: ${domain.replace(/_/g, ' ')}…`)
472 entities.push(...(((await ask('ha_get_entity_registry', { domain, include_disabled: false })).entities as unknown[]) ?? []))
473 states.push(...(((await ask('ha_list_entities', { domain, include_attributes: true, limit: 500 })).entities as unknown[]) ?? []))
474 }
475 return { location_name: location ?? 'Home', areas, devices, entities, states }
476 } catch (error) {
477 await fail($, `Could not read your home: ${firstLine(String(error).replace(/^Error: /, ''))}`)
478 return null
479 }
480}
481
482async function stateOf($: EngineInterface, entity: string): Promise<Record<string, unknown> | null> {
483 try {
484 const result = await $.mcp.call(server!, 'ha_get_state', { entity_ids: [entity] })
485 const body = parseJson(result.content.map(block => block.text ?? '').join('\n'))
486 const row = Array.isArray(body?.entities) ? (body!.entities as Record<string, unknown>[])[0] : null
487 const held = row && row.state && typeof row.state === 'object' ? (row.state as Record<string, unknown>) : null
488 return held ? { entity_id: entity, ...held } : null
489 } catch {
490 return null
491 }
492}
493
494// ---------------------------------------------------------------- small things
495
496async function setPhase($: EngineInterface, next: Phase, text: string | null) {
497 await update($, phase, () => next)
498 await update($, note, () => text)
499}
500
501async function fail($: EngineInterface, text: string) {
502 await setPhase($, 'failed', text)
503}
504
505/** A black screen of the right size, until the first frame. */
506function blank(columns: number, rows: number): string {
507 const words = new Uint32Array(columns * rows * 3)
508 for (let i = 0; i < columns * rows; i++) words[i * 3] = 0x2580
509 return base64(new Uint8Array(words.buffer))
510}
511
512function base64(bytes: Uint8Array): string {
513 const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
514 let out = ''
515 for (let i = 0; i < bytes.length; i += 3) {
516 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
517 out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]!
518 out += i + 1 < bytes.length ? A[(n >> 6) & 63]! : '='
519 out += i + 2 < bytes.length ? A[n & 63]! : '='
520 }
521 return out
522}
523
524function firstLine(text: string | null): string | null {
525 return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
526}
527
528/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
529function parseJson(raw: string): Record<string, unknown> | null {
530 const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
531 const start = text.search(/[[{]/)
532 const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
533 if (start < 0 || end <= start) return null
534 try {
535 const value = JSON.parse(text.slice(start, end + 1)) as unknown
536 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
537 } catch {
538 return null
539 }
540}
541hooks/keys.tsx 36 lines1// The strip under the screen: shows the status line and, once clicked, takes the
2// keyboard for the game. A terminal reports presses only; house.wad holds each
3// key for a moment and the terminal's auto-repeat keeps it held. Keys go to the
4// hooks module in batches, as a Client posts once a frame at most.
5import type { ClientModule } from 'claude-code'
6
7type Props = { text: string; hint: string }
8type Local = { queue: string[]; isFocused: boolean }
9
10const Keys: ClientModule<Props, Local> = (props, surface) => {
11 const { Box, Text } = surface.elements
12 if (surface.state === undefined) {
13 const local: Local = { queue: [], isFocused: false }
14 surface.setState(local)
15 surface.onKey(event => {
16 if (event.ctrl || event.meta) return
17 local.isFocused = true
18 local.queue.push(event.key)
19 })
20 surface.onPointer(event => {
21 if (event.type === 'down') local.isFocused = true
22 })
23 surface.every(30, () => {
24 if (local.queue.length > 0) surface.post({ keys: local.queue.splice(0, local.queue.length) })
25 })
26 }
27 return (
28 <Box flexDirection="column">
29 <Text wrap="truncate-end">{props.text}</Text>
30 <Text dimColor wrap="truncate-end">{props.hint}</Text>
31 </Box>
32 )
33}
34
35export default Keys
36types/index.d.ts 23 lines1/** Where a game is: what the pane draws. */
2export type Phase = 'idle' | 'checking' | 'downloading' | 'reading' | 'building' | 'playing' | 'ended' | 'failed'
3
4/** The game's status line, as house.wad's card shows it under the screen. */
5export type Status = { room: string; aim: string; last: string; world: string }
6
7declare module 'claude-code' {
8 interface PluginState {
9 'vome-doom': {
10 phase: Phase
11 /** One line about what is happening or went wrong. */
12 note: string | null
13 /** The screen in cells, once the game has said. */
14 size: { columns: number; rows: number } | null
15 status: Status | null
16 /** Which home: the name the game titles its level with. */
17 home: string | null
18 /** Auto mode refused one of the pane's calls: on which server, and which tool. */
19 blocked: { server: string; tool: string } | null
20 }
21 }
22}
23