// chipformer Space: a CHIP-8 player whose CPU is a transformer. // The WebAssembly engine (engine/src/wasm.rs) runs the model; this file only moves bytes in and // out, paces frames, reads keys and draws. Ported from the site's engine.ts and useArcade.ts. import { ASSET_BASE, ASSET_VERSION, GAMES, LAYERS, LIBRARY, MACHINE, MODEL_FACTS } from "./data.js"; const FRAME_MS = 1000 / 60; const PERSIST = 20; // instructions a pixel stays lit after it goes dark (hides XOR redraw flicker) const FADE = 0.42; // phosphor decay per frame const TAP_FRAMES = 3; const DEMO_SECONDS = 28; const PHOSPHOR = [221, 245, 158]; const GLASS = [8, 12, 10]; const HEADS = LAYERS.reduce((n, l) => n + l.heads.length, 0); const MAX_UNITS = Math.max(...LAYERS.map((l) => l.units)); const DEMOS = ["space-invaders", "tetris", "brix", "blinky", "br8kout", "ufo", "super-pong", "cave-explorer"] .map((id) => GAMES.find((g) => g.id === id)).filter(Boolean); const $ = (id) => document.getElementById(id); const fmt = (n) => Math.round(n).toLocaleString("en-US"); const hex = (v, n) => v.toString(16).toUpperCase().padStart(n, "0"); const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); // ---------------------------------------------------------------- engine class Engine { constructor(x) { this.x = x; } static async create(wasm, model) { const { instance } = await WebAssembly.instantiate(wasm, {}); const e = new Engine(instance.exports); const [ptr, len] = e.put(model); e.check(e.x.cf_load_model(ptr, len)); e.x.cf_free(ptr, len); return e; } put(bytes) { const ptr = this.x.cf_alloc(bytes.length); new Uint8Array(this.x.memory.buffer, ptr, bytes.length).set(bytes); return [ptr, bytes.length]; } check(rc) { if (rc !== 0) { const b = new Uint8Array(this.x.memory.buffer, this.x.cf_error_ptr(), this.x.cf_error_len()); throw new Error(new TextDecoder().decode(b)); } } start(rom, quirks) { const [rp, rl] = this.put(rom); const [qp, ql] = this.put(new TextEncoder().encode(quirks.join(","))); try { this.check(this.x.cf_start(rp, rl, qp, ql, 0)); } finally { this.x.cf_free(rp, rl); this.x.cf_free(qp, ql); } } frame(keys, ipf) { this.check(this.x.cf_frame(keys >>> 0, ipf)); } step() { this.check(this.x.cf_step()); } /** 64*32 bytes, 1 = lit (or dark for fewer than `persist` instructions). */ screen(persist) { return new Uint8Array(this.x.memory.buffer, this.x.cf_screen(persist), 64 * 32); } recentTokens() { const n = this.x.cf_tokens(0); return new Uint32Array(this.x.memory.buffer, this.x.cf_tokens_ptr(), n).slice(); } peek(addr) { return this.x.cf_peek(addr); } setTelemetry(on) { this.check(this.x.cf_telemetry(on ? 1 : 0)); } clearTelemetry() { this.x.cf_telemetry_clear(); } /** Attention distances since the last clear: one per head per token, heads in model order. */ telemetry() { const n = this.x.cf_telemetry_len(); return n ? new Uint32Array(this.x.memory.buffer, this.x.cf_telemetry_ptr(), n) : new Uint32Array(); } stats() { return { tokens: this.x.cf_stat(0), instructions: this.x.cf_stat(1), context: this.x.cf_stat(2) }; } nextInstruction() { const pc = this.peek(MACHINE.PC); return { pc, op: (this.peek(pc) << 8) | this.peek((pc + 1) & 0xfff) }; } } let enginePromise = null; function loadEngine(progress) { if (!enginePromise) { enginePromise = (async () => { const v = `?v=${ASSET_VERSION}`; const [wasm, model] = await Promise.all([ fetch(`${ASSET_BASE}/engine.wasm${v}`).then((r) => { if (!r.ok) throw new Error(`engine.wasm: HTTP ${r.status}`); return r.arrayBuffer(); }), fetchWithProgress(`${ASSET_BASE}/model.chipformer${v}`, progress), ]); return Engine.create(wasm, model); })(); enginePromise.catch(() => (enginePromise = null)); } return enginePromise; } async function fetchWithProgress(url, progress) { const res = await fetch(url); if (!res.ok || !res.body) throw new Error(`${url}: HTTP ${res.status}`); const total = Number(res.headers.get("content-length")) || 0; const reader = res.body.getReader(); const chunks = []; let got = 0; for (;;) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); got += value.length; if (total) progress(Math.min(1, got / total)); } progress(1); const out = new Uint8Array(got); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return out; } const romCache = new Map(); function loadRom(g) { if (!romCache.has(g.id)) { romCache.set(g.id, fetch(`${ASSET_BASE}/roms/${g.id}.ch8?v=${ASSET_VERSION}`).then(async (r) => { if (!r.ok) throw new Error(`${g.id}.ch8: HTTP ${r.status}`); return new Uint8Array(await r.arrayBuffer()); })); } return romCache.get(g.id); } // ---------------------------------------------------------------- keys /** Keyboard keys (KeyboardEvent.code) for each role; mirrors engine/src/games.rs. */ function roleKeys(role, two) { switch (role) { case "up": return two ? ["KeyW", "KeyA"] : ["ArrowUp", "KeyW"]; case "down": return two ? ["KeyS", "KeyD"] : ["ArrowDown", "KeyS"]; case "player2Up": return ["ArrowUp", "ArrowLeft"]; case "player2Down": return ["ArrowDown", "ArrowRight"]; case "left": return ["ArrowLeft", "KeyA"]; case "right": return ["ArrowRight", "KeyD"]; case "a": return ["Space", "KeyE"]; case "b": return ["Enter", "KeyQ"]; } return []; } /** The classic CHIP-8 keypad on a QWERTY keyboard: 123C/456D/789E/A0BF on 1234/QWER/ASDF/ZXCV. */ const CLASSIC = [ ["Digit1", 0x1], ["Digit2", 0x2], ["Digit3", 0x3], ["Digit4", 0xc], ["KeyQ", 0x4], ["KeyW", 0x5], ["KeyE", 0x6], ["KeyR", 0xd], ["KeyA", 0x7], ["KeyS", 0x8], ["KeyD", 0x9], ["KeyF", 0xe], ["KeyZ", 0xa], ["KeyX", 0x0], ["KeyC", 0xb], ["KeyV", 0xf], ]; const isTwoPlayer = (g) => Object.keys(g.keys).some((r) => r.startsWith("player2")); function keyBindings(g) { const two = isTwoPlayer(g); const map = new Map(); const used = new Set(); const add = (code, chip) => map.set(code, [...(map.get(code) ?? []), chip]); for (const [role, chip] of Object.entries(g.keys)) { for (const code of roleKeys(role, two)) add(code, chip); used.add(chip); } for (const [code, chip] of CLASSIC) if (!map.has(code) && !used.has(chip)) add(code, chip); return map; } const KEY_LABELS = { ArrowUp: "↑", ArrowDown: "↓", ArrowLeft: "←", ArrowRight: "→", Space: "Space", Enter: "Enter" }; const keyLabel = (code) => KEY_LABELS[code] ?? code.replace(/^Key|^Digit/, ""); const ROLE_LABELS = { up: "Up", down: "Down", left: "Left", right: "Right", a: "Action", b: "Second action", player2Up: "Player 2 up", player2Down: "Player 2 down", }; // ---------------------------------------------------------------- tokens function cellName(a) { if (a < 0x1000) return `M[${hex(a, 3)}]`; if (a < 0x1010) return `V${hex(a - 0x1000, 1)}`; const named = { 0x1010: "I", [MACHINE.PC]: "PC", 0x1012: "SP", 0x1013: "DT", 0x1014: "ST", [MACHINE.RNG]: "RNG", [MACHINE.CLS]: "CLS", [MACHINE.NULL]: "NULL" }; if (named[a]) return named[a]; if (a >= MACHINE.QUIRKS && a < MACHINE.QUIRKS + 6) return `Q.${MACHINE.QUIRK_NAMES[a - MACHINE.QUIRKS]}`; if (a >= 0x1020 && a < 0x1030) return `STACK${hex(a - 0x1020, 1)}`; if (a >= MACHINE.KEYS && a < MACHINE.KEYS + 16) return `KEY${hex(a - MACHINE.KEYS, 1)}`; if (a >= MACHINE.DISPLAY && a < MACHINE.NULL) { const i = a - MACHINE.DISPLAY; return `SCREEN[${Math.floor(i / 8)}:${i % 8}]`; } return `CELL ${hex(a, 4)}`; } function tokenText(t) { if (t < MACHINE.ADDR_BASE) return `=${hex(t - MACHINE.VAL_BASE, 2)}`; if (t < MACHINE.STEP) return `@${cellName(t - MACHINE.ADDR_BASE)}`; return t === MACHINE.STEP ? "STEP" : t === MACHINE.TICK ? "TICK" : t === MACHINE.BOOT ? "BOOT" : `#${t}`; } /** The most recent write (an ADDR token followed by its VAL) in a token window. */ function lastWrite(tokens) { for (let i = tokens.length - 2; i >= 0; i--) { const t = tokens[i]; if (t >= MACHINE.ADDR_BASE && t < MACHINE.STEP && tokens[i + 1] < MACHINE.ADDR_BASE) return { addr: t - MACHINE.ADDR_BASE, value: tokens[i + 1] - MACHINE.VAL_BASE }; } return null; } /** Mnemonic and a plain-English description of a CHIP-8 opcode. */ function describe(op) { const x = (op >> 8) & 0xf, y = (op >> 4) & 0xf, n = op & 0xf, nn = op & 0xff, nnn = op & 0xfff; const vx = `V${hex(x, 1)}`, vy = `V${hex(y, 1)}`; const d = (text) => ({ code: hex(op, 4), text }); switch (op >> 12) { case 0x0: return d(op === 0x00e0 ? "Clear the screen" : op === 0x00ee ? "Return from a subroutine" : "Machine-code call (ignored)"); case 0x1: return d(`Jump to ${hex(nnn, 3)}`); case 0x2: return d(`Call the subroutine at ${hex(nnn, 3)}`); case 0x3: return d(`Skip the next instruction if ${vx} = ${hex(nn, 2)}`); case 0x4: return d(`Skip the next instruction if ${vx} ≠ ${hex(nn, 2)}`); case 0x5: return d(`Skip the next instruction if ${vx} = ${vy}`); case 0x6: return d(`Set ${vx} to ${hex(nn, 2)}`); case 0x7: return d(`Add ${hex(nn, 2)} to ${vx}`); case 0x8: return d({ 0x0: `Copy ${vy} into ${vx}`, 0x1: `${vx} |= ${vy}`, 0x2: `${vx} &= ${vy}`, 0x3: `${vx} ^= ${vy}`, 0x4: `${vx} += ${vy}, VF = carry`, 0x5: `${vx} -= ${vy}, VF = no borrow`, 0x6: "Shift right, VF = the bit shifted out", 0x7: `${vx} = ${vy} − ${vx}, VF = no borrow`, 0xe: "Shift left, VF = the bit shifted out", }[n] ?? "Unknown arithmetic (no effect)"); case 0x9: return d(`Skip the next instruction if ${vx} ≠ ${vy}`); case 0xa: return d(`Point I at ${hex(nnn, 3)}`); case 0xb: return d(`Jump to ${hex(nnn, 3)} plus a register`); case 0xc: return d(`${vx} = random byte & ${hex(nn, 2)}`); case 0xd: return d(`Draw an 8×${n} sprite at (${vx}, ${vy}), VF = collision`); case 0xe: return d(nn === 0x9e ? `Skip if the key in ${vx} is held` : nn === 0xa1 ? `Skip if the key in ${vx} is not held` : "Unknown key instruction (no effect)"); case 0xf: return d({ 0x07: `${vx} = delay timer`, 0x0a: `Wait for a key, store it in ${vx}`, 0x15: `Delay timer = ${vx}`, 0x18: `Sound timer = ${vx}`, 0x1e: `I += ${vx}`, 0x29: `Point I at the font glyph for ${vx}`, 0x33: `Write the decimal digits of ${vx} to memory`, 0x55: `Store V0–${vx} in memory`, 0x65: `Load V0–${vx} from memory`, }[nn] ?? "Unknown instruction (no effect)"); } return d("Unknown instruction"); } // ---------------------------------------------------------------- state const S = { mode: "boot", // boot | attract | howto | play | paused | operator | gameover | error game: GAMES.find((g) => g.id === location.hash.slice(1)) ?? DEMOS[0] ?? GAMES[0], engine: null, progress: 0, error: "", bindings: new Map(), tapChips: new Set(), pressed: new Map(), // keyboard code -> CHIP-8 keys touch: new Set(), // CHIP-8 keys held on the touch controls pulses: new Map(), // tap keys: frames left glow: new Float32Array(64 * 32), ema: new Float32Array(HEADS), sound: true, audio: null, loadSeq: 0, }; function setMode(m) { S.mode = m; renderOverlay(); renderTools(); renderInspector(); } function setGame(g) { S.game = g; S.bindings = keyBindings(g); S.tapChips = new Set(g.tap.map((r) => g.keys[r]).filter((c) => c !== undefined)); renderChips(); renderHints(); renderTouch(); renderCredit(); } async function load(g) { const seq = ++S.loadSeq; const [e, rom] = await Promise.all([loadEngine((p) => { S.progress = p; if (S.mode === "boot") renderOverlay(); }), loadRom(g)]); if (seq !== S.loadSeq) return false; // a newer choice won e.start(rom, g.quirks); e.setTelemetry(true); S.engine = e; S.glow.fill(0); S.pressed.clear(); S.touch.clear(); S.pulses.clear(); setGame(g); drawScreen(); return true; } function fail(err) { S.error = err instanceof Error ? err.message : String(err); beep(false); setMode("error"); } async function startDemo(g) { try { if (await load(g)) setMode("attract"); } catch (err) { fail(err); } } async function choose(g, then = "howto") { try { if (await load(g)) { setMode(then); history.replaceState(null, "", `#${g.id}`); } } catch (err) { fail(err); } } // ---------------------------------------------------------------- drawing const canvas = $("screen"); const ctx = canvas.getContext("2d"); const small = document.createElement("canvas"); small.width = 64; small.height = 32; const sctx = small.getContext("2d"); const img = sctx.createImageData(64, 32); function drawScreen() { const e = S.engine; if (!e) return; const vis = e.screen(PERSIST), g = S.glow; for (let i = 0; i < 2048; i++) { g[i] = vis[i] ? 1 : g[i] * FADE; for (let c = 0; c < 3; c++) img.data[i * 4 + c] = GLASS[c] + (PHOSPHOR[c] - GLASS[c]) * g[i]; img.data[i * 4 + 3] = 255; } sctx.putImageData(img, 0, 0); ctx.globalCompositeOperation = "source-over"; ctx.imageSmoothingEnabled = false; ctx.drawImage(small, 0, 0, canvas.width, canvas.height); if ("filter" in ctx) { ctx.globalCompositeOperation = "lighter"; ctx.globalAlpha = 0.38; ctx.imageSmoothingEnabled = true; ctx.filter = "blur(16px)"; ctx.drawImage(small, 0, 0, canvas.width, canvas.height); ctx.filter = "none"; ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; } // LCD pixel grid, drawn into the canvas ctx.fillStyle = "rgba(0, 0, 0, 0.42)"; const cw = canvas.width / 64, ch = canvas.height / 32; for (let x = 1; x < 64; x++) ctx.fillRect(Math.round(x * cw) - 1, 0, 2, canvas.height); for (let y = 1; y < 32; y++) ctx.fillRect(0, Math.round(y * ch) - 1, canvas.width, 2); } // ---------------------------------------------------------------- the live model panel const headCells = []; function buildLayers() { const ol = $("layers"); LAYERS.forEach((l, i) => { const li = document.createElement("li"); li.className = "layer"; li.title = l.heads.length ? `layer ${i} heads read: ${l.heads.join(", ")}` : `layer ${i}: arithmetic only`; li.innerHTML = `L${String(i).padStart(2, "0")}` + ``; const heads = li.querySelector(".heads"); for (const h of l.heads) { const s = document.createElement("span"); s.className = "head"; s.title = h; heads.appendChild(s); headCells.push(s); } ol.appendChild(li); }); $("embed-note").textContent = `${fmt(MODEL_FACTS.vocab)} → ${MODEL_FACTS.dModel} dims`; } let lastPanel = 0; function updatePanel(e, force = false) { const d = e.telemetry(); const n = Math.floor(d.length / HEADS); for (let h = 0; h < HEADS && n; h++) { let sum = 0; for (let t = 0; t < n; t++) sum += Math.log2(d[t * HEADS + h] + 1); S.ema[h] = S.ema[h] * 0.6 + (sum / n / 20) * 0.4; } const now = performance.now(); if (!force && now - lastPanel < 50) return; lastPanel = now; for (let h = 0; h < HEADS; h++) headCells[h].style.opacity = String(0.3 + 0.7 * Math.min(1, S.ema[h])); const toks = e.recentTokens(); $("tokens-in").textContent = Array.from(toks.slice(-7), tokenText).join(" "); const w = lastWrite(toks.slice(-40)); if (w) { $("next-cell").textContent = `@${cellName(w.addr)}`; $("next-val").textContent = `=${hex(w.value, w.value > 0xff ? 3 : 2)}`; } $("next-count").textContent = `#${fmt(e.stats().tokens)}`; } function renderInspector() { const box = $("inspector"); const show = (S.mode === "paused" || S.mode === "operator") && S.engine; box.hidden = !show; if (!show) return; const next = S.engine.nextInstruction(); const d = describe(next.op); $("insp-pc").textContent = `Next instruction · ${hex(next.pc, 3)}`; $("insp-code").textContent = d.code; $("insp-text").textContent = d.text; } // ---------------------------------------------------------------- sound function beep(on) { if (on && S.sound && !S.audio) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return; const ac = new Ctx(); const osc = ac.createOscillator(), gain = ac.createGain(); osc.type = "square"; osc.frequency.value = 440; gain.gain.value = 0; osc.connect(gain).connect(ac.destination); osc.start(); S.audio = { ac, gain }; } const a = S.audio; if (a) a.gain.gain.setTargetAtTime(on && S.sound ? 0.03 : 0, a.ac.currentTime, 0.008); } // ---------------------------------------------------------------- main loop let acc = 0, prevT = performance.now(); let demoKeys = 0, demoUntil = 0, demoSince = performance.now(); let last = { t: performance.now(), tokens: 0, instructions: 0 }; let fullSince = 0; // game-over rule: when the watched screen region became lit function loop(now) { requestAnimationFrame(loop); const e = S.engine, m = S.mode, g = S.game; if (!e || !(m === "attract" || m === "play")) { prevT = now; acc = 0; return; } // The game advances at exactly 60 frames a second whatever the display's refresh rate. // Catch up at most 3 frames after a stall. acc += Math.min(100, now - prevT); prevT = now; if (acc < FRAME_MS) return; if (m === "attract") { if (now > demoUntil) { const roles = Object.values(g.keys); demoKeys = Math.random() < 0.6 && roles.length ? 1 << roles[Math.floor(Math.random() * roles.length)] : 0; demoUntil = now + 100 + Math.random() * 450; } if (now - demoSince > DEMO_SECONDS * 1000) { demoSince = now; acc = 0; void startDemo(DEMOS[(DEMOS.indexOf(g) + 1) % DEMOS.length]); return; } } e.clearTelemetry(); for (let n = 0; acc >= FRAME_MS; n++) { if (n === 3) { acc = 0; break; } acc -= FRAME_MS; let keys = 0; if (m === "play") { for (const chips of S.pressed.values()) for (const c of chips) if (!S.tapChips.has(c)) keys |= 1 << c; for (const c of S.touch) if (!S.tapChips.has(c)) keys |= 1 << c; for (const [c, left] of S.pulses) { if (left > 0) { keys |= 1 << c; S.pulses.set(c, left - 1); } } } else keys = demoKeys; try { e.frame(keys, g.ipf); } catch (err) { fail(err); return; } } drawScreen(); updatePanel(e); const rule = g.gameOver; if (m === "play" && rule) { const px = e.screen(0); let lit = false; for (const y of rule.rows) for (let x = rule.cols[0]; x <= rule.cols[1]; x++) lit ||= px[y * 64 + x] === 1; if (!lit) fullSince = 0; else if (!fullSince) fullSince = now; else if (now - fullSince > rule.seconds * 1000) { fullSince = 0; beep(false); setMode("gameover"); return; } } beep(m === "play" && e.peek(0x1014) > 0); if (now - last.t > 500) { const s = e.stats(), dt = (now - last.t) / 1000; const tps = Math.max(0, (s.tokens - last.tokens) / dt), ips = Math.max(0, (s.instructions - last.instructions) / dt); $("readout").textContent = `${fmt(tps)} tokens/s · ${fmt(ips)} instructions/s · ${(ips / (g.ipf * 60)).toFixed(1)}× the game's normal speed · running in your browser`; last = { t: now, tokens: s.tokens, instructions: s.instructions }; } } // ---------------------------------------------------------------- actions function step() { const e = S.engine; if (!e) return; try { e.clearTelemetry(); e.step(); } catch (err) { fail(err); return; } drawScreen(); updatePanel(e, true); renderInspector(); } function pause() { if (S.mode !== "play") return; beep(false); setMode("paused"); } function resume() { setMode("play"); } function stepAction() { if (S.mode !== "operator") { beep(false); setMode("operator"); } step(); } function restart() { void choose(S.game, "play"); } /** Enter / Space / Esc outside play mode. */ function nav(action) { switch (S.mode) { case "attract": if (action === "start") void choose(S.game); break; case "howto": if (action === "start") setMode("play"); break; case "play": if (action === "back") pause(); break; case "paused": if (action === "start") resume(); break; case "operator": if (action === "start") step(); else if (action === "back") setMode("paused"); break; case "gameover": if (action === "start") restart(); break; case "error": if (action === "start") void choose(S.game); break; } } // ---------------------------------------------------------------- rendering function renderChips() { const box = $("chips"); if (!box.childElementCount) { for (const g of GAMES) { const b = document.createElement("button"); b.className = "chip"; b.textContent = g.title; b.dataset.id = g.id; b.onclick = () => void choose(g); box.appendChild(b); } } for (const b of box.children) b.setAttribute("aria-pressed", String(b.dataset.id === S.game.id && S.mode !== "attract")); } function keysHtml(g, max, p1 = "Player 1") { const two = isTwoPlayer(g); return Object.keys(g.keys).slice(0, max).map((r) => `${roleKeys(r, two).slice(0, 2).map((k) => `${esc(keyLabel(k))}`).join("")}` + `${two && (r === "up" || r === "down") ? `${p1} ${r}` : ROLE_LABELS[r]}`).join(""); } function renderHints() { $("hints").innerHTML = keysHtml(S.game, 5) + `EscPause`; } function renderOverlay() { const o = $("overlay"), g = S.game; o.className = "overlay"; const dim = (html) => { o.className = "overlay center dim"; o.innerHTML = `
Loading the transformer
` + `${fmt(MODEL_FACTS.nonzero)} weights · ${(MODEL_FACTS.modelBytes / 1e6).toFixed(1)} MB
`); break; case "attract": o.className = "overlay corner"; o.innerHTML = `Demo · ${esc(g.title)} · random input` + ``; break; case "howto": dim(`${esc(g.title)}
${esc(g.controls)}.
` + `or press any game key
`); break; case "paused": dim(`Paused
Game over
${g.gameOver ? `${esc(g.gameOver.message)}.
` : ""}` + ``); break; case "error": dim(`Something went wrong
${esc(S.error)}
` + ``); break; default: o.innerHTML = ""; } o.style.alignItems = ""; } $("overlay").addEventListener("click", (ev) => { const act = ev.target.closest("[data-act]")?.dataset.act; if (act === "choose") void choose(S.game); else if (act === "start") setMode("play"); else if (act === "resume") resume(); else if (act === "step") stepAction(); else if (act === "restart") restart(); }); function renderTools() { const play = $("btn-play"); play.textContent = S.mode === "play" ? "Pause" : "Play"; $("btn-step").disabled = S.mode === "boot" || S.mode === "error"; renderChips(); } $("btn-play").onclick = () => { const m = S.mode; if (m === "play") pause(); else if (m === "paused" || m === "operator" || m === "howto") resume(); else if (m === "gameover") restart(); else void choose(S.game, "play"); }; $("btn-step").onclick = stepAction; $("btn-restart").onclick = restart; $("btn-sound").onclick = (ev) => { S.sound = !S.sound; ev.currentTarget.setAttribute("aria-pressed", String(S.sound)); if (!S.sound) beep(false); }; $("insp-step").onclick = stepAction; function renderTouch() { const box = $("touch"), g = S.game; const pad = (role, label, w, h, round = false) => { const missing = g.keys[role] === undefined; return ``; }; if (isTwoPlayer(g)) { box.innerHTML = `${esc(g.description)}
` : ""; $("credit").innerHTML = `Now playing · ${esc(g.year ?? "CHIP-8")} · ${esc(g.license)}` + `