# coccoon — LLM starter kit coccoon is a retro quantum game engine. Games run on a fixed 32x18 tile grid and are authored as a small TypeScript module. This document contains the FULL source of the engine, the quantum simulator, and every demo game, so you can write a new game with no other context. ## How to write a cartridge The output is a single .js (or .ts) file. Write the class, export it, and hand the file to the user. You do NOT need to locate, create, or modify any existing project — the file stands alone. There is no repo to find, no tsconfig.json, no package.json, no build step. If a web app is present it can be dropped into lib/games/, but that is optional: the user pastes the file straight into the Create editor at /create and presses Run. A cartridge is authored EXACTLY like the demo games below: import { Sprite, Text, color, GRID_W, GRID_H, type Coccoon, type Game } from "@/lib/coccoon" export class MyGame implements Game { ready(engine: Coccoon) { // called once: register images, create sprites and text } process(delta: number, engine: Coccoon) { // called every frame (~30fps): read engine.update().key_presses, update sprites } } Type annotations are OPTIONAL. The editor transpiles TypeScript, but plain JavaScript works too — drop the ": Coccoon", ": number", and "implements Game" and the same class runs unchanged. Write whichever you prefer. Rules: - Import only from "@/lib/coccoon" and "@/lib/micromoth". Everything you need is exported from those two modules (their full source is below). - Export exactly one class implementing Game (ready + process). - The grid is GRID_W (32) wide by GRID_H (18) tall. y is measured from the BOTTOM (y=0 is the bottom row, y=17 is the top row). You place sprites on cells; higher z draws on top. - Input key codes: 0=Up 1=Right 2=Down 3=Left (arrows and WASD are synonyms), 4=Start (Space/Enter), 5=I 6=J 7=K 8=L. A held key repeats every frame; diff against the previous frame for one-shot presses. - Audio: new SoundList(engine, ["/audio/foo.wav"]) then new Sound(engine, id) (add LOOP for looping music). Audio starts after the first input. - Uploaded media (PNGs/WAVs): a cartridge is a single text file and cannot embed binary data. In the Create editor, click "+ Media" to upload PNGs/WAVs, then reference each by its filename with the asset() helper (imported from "@/lib/coccoon"). It returns a runtime URL you pass straight to ImageList or SoundList: import { ImageList, SoundList, Sound, asset } from "@/lib/coccoon" new ImageList(engine, [asset("hero.png")]) // image id 0 new SoundList(engine, [asset("beep.wav")]) // sound id 0 new Sound(engine, 0) // play it asset("name") throws if no file with that name was uploaded, so upload first. Without uploads, stick to color()/Colors sprites (procedural pixel art). - Paste your finished class into the Create editor and press Run. ## Sizing & layout guardrails (READ THIS or your text will be unreadable) The canvas is a FIXED 1280x720 pixels: GRID_W(32) x GRID_H(18) cells, each cell CELL = 40px square. Two different coordinate systems coexist, and mixing them up is the most common mistake: - Sprite x/y/size and Text x/y/width/height are in GRID CELLS (0..31, 0..17), NOT pixels. A Text with width=10, height=2 occupies 400x80 px. - The Text constructor argument order is NOT the same as Sprite's — do not guess it from Sprite. The exact signature is: new Text(engine, text, width, height, x, y, fontSize?, fontColor?, bgColor?) width/height come BEFORE x/y. width, height, x, y are in grid cells; x/y default to 0 and use the SAME bottom-up axis as Sprite (y=0 is the bottom row, NOT the top — it does not follow CSS top-down convention). fontSize is in raw canvas pixels (default 16); fontColor defaults to black and bgColor to white. Getting the order wrong throws no error — text just lands in the wrong place at the wrong size. - Text fontSize is in RAW CANVAS PIXELS, not cells and not CSS points. On a 720px-tall canvas, 12-16px text is nearly invisible. Use these benchmarks: - Titles / headers: 36-56 px - Subtitles / status: 24-28 px - Small / micro labels: 16-20 px (never below 14) The Text default fontSize is 16 — fine for a small HUD label, too small for a title. Always set fontSize explicitly for headings. - Text boxes clip and word-wrap to their box; they do NOT auto-grow. Line height is fontSize * 1.35 plus ~6px padding. Budget box HEIGHT (in cells) so every line fits: at fontSize 24 one line needs ~1 cell of height, so a 2-3 line box needs height 2-3. Undersized boxes cut off text. - y is bottom-up: put a top header around y = 15..16, a bottom HUD around y = 0..1. Remember a box of height h is anchored at its bottom-left cell, so a header at the top is roughly y = GRID_H - height. ## Quantum: local (MicroMoth) vs. remote (Atlas) — pick by TIMING, not preference These are NOT interchangeable. They exist for opposite purposes: - MicroMoth (LOCAL, in-browser): import { MicroMoth } from "@/lib/micromoth". A synchronous statevector simulator with 0ms latency, no key, no network. USE IT FOR real-time mechanics: per-frame logic, button-press reactions, small 1-6 qubit circuits that must resolve this frame. See Qubit Park. IMPORTANT: MicroMoth is a single namespace object; QuantumCircuit and simulate are properties on it, NOT standalone named exports. Import { MicroMoth } and reach through it — do NOT write import { QuantumCircuit, simulate } (that yields "QuantumCircuit is not a constructor" at runtime). import { MicroMoth } from "@/lib/micromoth" const qc = new MicroMoth.QuantumCircuit(1) qc.h(0) const probs = MicroMoth.simulate(qc, 1024, "probabilities_dict") // probs["0"] ~= 0.5, probs["1"] ~= 0.5 // simulate modes: "counts" | "statevector" | "probabilities_dict" | "memory" - Atlas (REMOTE, the Moth platform): an ASYNCHRONOUS job pipeline. You POST a job and POLL for the result, which can take seconds to minutes (real QPUs / heavy simulators). USE IT FOR loading screens, level generation / prefetch, procedural content (e.g. Quantum Blur heightmaps), or turn-based phases. See quantum-caverns.ts, which submits a job during generation, not in-loop. HARD RULE: NEVER await an Atlas job in response to real-time input (movement, firing, a button press). That stalls the game for seconds. If a mechanic needs quantum results every frame, use MicroMoth. If it needs Atlas, move the call to a loading / transition / background-prefetch phase and read the cached result during gameplay. Treat Atlas as a background job pipeline, not a drop-in for MicroMoth.simulate(). ## Moth / Atlas platform API - Platform & keys: https://platform.mothquantum.com (one key runs every engine) - API base: https://api.mothquantum.com - Full OpenAPI spec: https://api.mothquantum.com/openapi.json (fetch this for the authoritative, up-to-date list of endpoints, request/response schemas, and parameters; prefer it over the summary below if they ever disagree) - Flow (see app/api/moth-blur/route.ts for a working proxy): 1. POST /api/v1/engines/{engine-id}/process -> 202 { job_id, status } 2. GET /api/v1/jobs/{job_id}/status -> { status, result? } (poll; can take ~2 min) 3. GET /api/v1/jobs/{job_id}/result -> { result: ... } (fallback if /status has no inline result) Poll /jobs/{job_id}/status, NOT /jobs/{job_id}. The plain /jobs/{job_id} record is eventually-consistent and can report "queued" for minutes after the job has actually completed; /status reflects true state within seconds and embeds result.output on completion. Send the key as the "X-API-Key" header. List engines with GET /api/v1/engines. --- ## lib/coccoon.ts — the engine The whole engine: the 32x18 grid, ImageList/Sprite/Text, color/Colors, SoundList/Sound/LOOP audio, the asset() helper for uploaded PNGs/WAVs, per-frame input (key_presses), and the Game interface. A cartridge imports from here. ```ts // coccoon — quantum game engine, HTML5 Canvas backend. // // Web port of coccoon.gd (https://github.com/moth-quantum/coccoon). // The original renders a virtual 32x18 cell grid using Godot scene nodes; // this version renders the same grid to a . The public API // (ImageList, Sprite, Text, update) mirrors the GDScript engine so game // code ports across almost verbatim. export const FPS = 30 export const CELL = 40 export const GRID_W = 32 export const GRID_H = 18 // Colors use 0..1 float channels to match Godot's Color type. export type Color = { r: number; g: number; b: number; a: number } export const Colors = { BLACK: { r: 0, g: 0, b: 0, a: 1 } as Color, WHITE: { r: 1, g: 1, b: 1, a: 1 } as Color, } export function color(r: number, g: number, b: number, a = 1): Color { return { r, g, b, a } } function toCss(c: Color): string { const to255 = (v: number) => Math.round(Math.max(0, Math.min(1, v)) * 255) return `rgba(${to255(c.r)}, ${to255(c.g)}, ${to255(c.b)}, ${c.a})` } // Key codes surfaced by update(). WASD mirrors the arrow keys as a synonymous // d-pad (up/right/down/left = 0/1/2/3), IJKL are the four face buttons // (5/6/7/8), and Space/Enter (4) is start. Both arrow and WASD keys produce the same // direction codes, so games only ever read the direction, never which key. const KEY_MAP: Record = { ArrowUp: 0, ArrowRight: 1, ArrowDown: 2, ArrowLeft: 3, KeyW: 0, KeyD: 1, KeyS: 2, KeyA: 3, Space: 4, Enter: 4, KeyI: 5, KeyJ: 6, KeyK: 7, KeyL: 8, Escape: -1, } // A tile carved out of a larger spritesheet: one shared image element, drawn // from a source rectangle. Lets a game register hundreds of 8x8 sprites that // all live in a single PNG instead of hundreds of separate files. export type SheetTile = { src: string; sx: number; sy: number; sw: number; sh: number } type ImageEntry = string | Color | SheetTile type LoadedImage = | { kind: "image"; el: HTMLImageElement } | { kind: "color"; css: string } | { kind: "tile"; el: HTMLImageElement; sx: number; sy: number; sw: number; sh: number } export interface InputState { key_presses: number[] clicks: unknown[] } // A game implements ready() once and process(delta) every frame. export interface Game { ready(engine: Coccoon): void process(delta: number, engine: Coccoon): void } export class Sprite { _engine: Coccoon _imageId: number _size: number _x: number _y: number z: number angle: number flip_h: boolean flip_v: boolean constructor( engine: Coccoon, imageId: number, x = 0, y = 0, z = 0, size = 1, angle = 0, flip_h = false, flip_v = false, ) { this._engine = engine this._imageId = imageId this._size = size this._x = x this._y = y this.z = z this.angle = angle this.flip_h = flip_h this.flip_v = flip_v engine._sprites.push(this) } get image_id(): number { return this._imageId } set image_id(v: number) { this._imageId = v } get x(): number { return this._x } set x(v: number) { this._x = v } get y(): number { return this._y } set y(v: number) { this._y = v } get size(): number { return this._size } set size(v: number) { this._size = v } _draw(ctx: CanvasRenderingContext2D): void { const img = this._engine._images[this._imageId] if (!img) return const s = this._size * CELL // y is measured from the bottom of the grid, like the original engine. const px = this._x * CELL const py = (GRID_H - this._y - this._size) * CELL ctx.save() ctx.translate(px + s / 2, py + s / 2) if (this.angle) ctx.rotate((this.angle * Math.PI) / 180) ctx.scale(this.flip_h ? -1 : 1, this.flip_v ? -1 : 1) if (img.kind === "color") { ctx.fillStyle = img.css ctx.fillRect(-s / 2, -s / 2, s, s) } else if (img.kind === "tile") { ctx.imageSmoothingEnabled = false ctx.drawImage(img.el, img.sx, img.sy, img.sw, img.sh, -s / 2, -s / 2, s, s) } else { ctx.imageSmoothingEnabled = false ctx.drawImage(img.el, -s / 2, -s / 2, s, s) } ctx.restore() } } export class Text { _engine: Coccoon text: string _width: number _height: number _x: number _y: number fontSize: number fontColor: Color bgColor: Color constructor( engine: Coccoon, text: string, width: number, height: number, x = 0, y = 0, fontSize = 16, fontColor: Color = Colors.BLACK, backgroundColor: Color = Colors.WHITE, ) { this._engine = engine this.text = text this._width = width this._height = height this._x = x this._y = y this.fontSize = fontSize this.fontColor = fontColor this.bgColor = backgroundColor engine._texts.push(this) } set x(v: number) { this._x = v } set y(v: number) { this._y = v } set_font_color(c: Color): void { this.fontColor = c } set_background_color(c: Color): void { this.bgColor = c } set_border_color(_c: Color): void { // not implemented, matching the original engine } _draw(ctx: CanvasRenderingContext2D): void { const px = this._x * CELL const py = (GRID_H - this._y - this._height) * CELL const w = this._width * CELL const h = this._height * CELL ctx.fillStyle = toCss(this.bgColor) ctx.fillRect(px, py, w, h) if (this.fontColor.a <= 0 || !this.text) return ctx.fillStyle = toCss(this.fontColor) ctx.font = `${this.fontSize}px "Press Start 2P", ui-monospace, monospace` ctx.textBaseline = "top" const lineHeight = this.fontSize * 1.35 const pad = 6 let cursorY = py + pad const maxWidth = w - pad * 2 for (const rawLine of this.text.split("\n")) { // word-wrap each logical line to the text box width const words = rawLine.split(" ") let line = "" for (const word of words) { const test = line ? line + " " + word : word if (ctx.measureText(test).width > maxWidth && line) { ctx.fillText(line, px + pad, cursorY) cursorY += lineHeight line = word } else { line = test } } ctx.fillText(line, px + pad, cursorY) cursorY += lineHeight } } } // ---- Uploaded assets ----------------------------------------------------- // A cartridge is a single text file, so it can't embed binary PNGs or WAVs. // Instead, files uploaded in the create editor are registered here by filename // and resolved to a runtime URL (a blob URL in the browser). `asset("hero.png")` // returns that URL, ready to hand to ImageList or SoundList. The create page // repopulates this registry before each run. const _assetRegistry = new Map() export function registerAsset(name: string, url: string): void { _assetRegistry.set(name, url) } export function clearAssets(): void { _assetRegistry.clear() } export function asset(name: string): string { const url = _assetRegistry.get(name) if (!url) { const known = [..._assetRegistry.keys()] throw new Error( `No uploaded asset named "${name}".` + (known.length ? ` Available: ${known.join(", ")}.` : " Upload a PNG or WAV in the editor first, then reference it by filename."), ) } return url } export class ImageList { _entries: ImageEntry[] constructor(engine: Coccoon, entries: ImageEntry[]) { // Creating a new ImageList clears any existing game sprites, like the // original engine's _clear_game_nodes(). engine._clearGameNodes() this._entries = entries engine._loadImages(entries) } } // ---- Audio --------------------------------------------------------------- // Mirrors the SoundList + Sound model of qisge (the engine coccoon was ported // from). SoundList registers audio clips by URL, exactly like ImageList // registers images. A Sound is a playback channel: set its playmode to start // or stop it, and volume/pitch/note update live while it plays. Web Audio // backs it here where Godot used AudioStreamPlayer nodes. // playmode values, matching qisge's Sound.playmode. export const STOP = 0 export const PLAY = 1 export const LOOP = 2 type LoadedSound = { url: string buffer: AudioBuffer | null // Channels that asked to play before this clip finished decoding. pending: Set } export class SoundList { _entries: string[] constructor(engine: Coccoon, entries: string[]) { // A fresh SoundList silences whatever was playing, like ImageList clears // the old sprites. engine._stopAllChannels() this._entries = entries engine._loadSounds(entries) } } export class Sound { _engine: Coccoon _soundId: number _playmode: number _volume: number _pitch: number _note: number _source: AudioBufferSourceNode | null = null _gain: GainNode | null = null constructor(engine: Coccoon, soundId: number, playmode = PLAY, volume = 1, pitch = 1, note = 0) { this._engine = engine this._soundId = soundId this._playmode = playmode this._volume = volume this._pitch = pitch this._note = note engine._channels.push(this) if (playmode !== STOP) this._begin() } get playmode(): number { return this._playmode } set playmode(v: number) { if (v === this._playmode) return this._playmode = v if (v === STOP) this._stopSource() else this._begin() } get volume(): number { return this._volume } set volume(v: number) { this._volume = v const ctx = this._engine._audioCtx if (this._gain && ctx) this._gain.gain.setValueAtTime(v, ctx.currentTime) } get pitch(): number { return this._pitch } set pitch(v: number) { this._pitch = v this._applyRate() } get note(): number { return this._note } set note(v: number) { this._note = v this._applyRate() } // note shifts pitch in equal-tempered semitones, on top of the pitch factor. private _rate(): number { return this._pitch * Math.pow(2, this._note / 12) } private _applyRate(): void { const ctx = this._engine._audioCtx if (this._source && ctx) this._source.playbackRate.setValueAtTime(this._rate(), ctx.currentTime) } private _begin(): void { const eng = this._engine const snd = eng._sounds[this._soundId] if (!snd) return eng._ensureAudio() if (!snd.buffer) { // Not decoded yet — start as soon as it is. snd.pending.add(this) return } this._playBuffer(snd.buffer) } // Called by the engine once a pending clip finishes decoding. _playBuffer(buffer: AudioBuffer): void { const eng = this._engine const ctx = eng._audioCtx if (!ctx || !eng._masterGain) return this._stopSource() const src = ctx.createBufferSource() src.buffer = buffer src.loop = this._playmode === LOOP src.playbackRate.value = this._rate() const gain = ctx.createGain() gain.gain.value = this._volume src.connect(gain) gain.connect(eng._masterGain) src.start() this._source = src this._gain = gain if (this._playmode === PLAY) { src.onended = () => { if (this._source === src) { this._source = null this._gain = null this._playmode = STOP } } } } _stopSource(): void { if (this._source) { try { this._source.onended = null this._source.stop() } catch { // already stopped } this._source.disconnect() this._source = null } if (this._gain) { this._gain.disconnect() this._gain = null } const snd = this._engine._sounds[this._soundId] if (snd) snd.pending.delete(this) } } export class Coccoon { _canvas: HTMLCanvasElement _ctx: CanvasRenderingContext2D _images: LoadedImage[] = [] _sprites: Sprite[] = [] _texts: Text[] = [] _sounds: LoadedSound[] = [] _channels: Sound[] = [] _audioCtx: AudioContext | null = null _masterGain: GainNode | null = null _inputState: InputState = { key_presses: [], clicks: [] } // Keys that have been surfaced by at least one update() since being pressed. _readSincePress = new Set() // Keys released before any update() observed them; removal is deferred so a // fast tap (keydown+keyup within one frame) is still visible for one frame. _pendingRelease = new Set() _game: Game | null = null _raf = 0 _lastTime = 0 _accum = 0 _running = false onEscape: (() => void) | null = null private _keyDown = (e: KeyboardEvent) => { const codeKey = e.code if (!(codeKey in KEY_MAP)) return // A keypress is a user gesture — unblock audio the browser held suspended. if (this._audioCtx && this._audioCtx.state === "suspended") void this._audioCtx.resume() if (e.code === "Space" || e.code === "Enter" || e.code.startsWith("Arrow")) e.preventDefault() const code = KEY_MAP[codeKey] if (code === -1) { if (this.onEscape) this.onEscape() return } this._pendingRelease.delete(code) if (!this._inputState.key_presses.includes(code)) { this._inputState.key_presses.push(code) this._readSincePress.delete(code) } } private _keyUp = (e: KeyboardEvent) => { const codeKey = e.code if (!(codeKey in KEY_MAP)) return const code = KEY_MAP[codeKey] if (this._readSincePress.has(code)) { this._removeKey(code) } else { // Not yet observed by a frame — keep it one more update() then drop it. this._pendingRelease.add(code) } } private _removeKey(code: number): void { const idx = this._inputState.key_presses.indexOf(code) if (idx !== -1) this._inputState.key_presses.splice(idx, 1) this._readSincePress.delete(code) this._pendingRelease.delete(code) } // Codes currently held via gamepad, so releases can be synthesized when a // button/stick returns to neutral without disturbing keyboard-held codes. _padHeld = new Set() // Poll connected gamepads once per frame and translate them into the same // codes the keyboard produces: d-pad + left stick -> 0..3, face buttons -> // 4 (A/bottom = start-equivalent, matches Space) and 5..8 (the four buttons), // Start/Back -> Escape. Standard-mapping layout (Xbox/PlayStation/etc). private _pollGamepad(): void { if (typeof navigator === "undefined" || !navigator.getGamepads) return const pads = navigator.getGamepads() let pad: Gamepad | null = null for (const p of pads) { if (p && p.connected) { pad = p break } } const want = new Set() if (pad) { const pressed = (i: number) => !!pad!.buttons[i]?.pressed const axis = (i: number) => pad!.axes[i] ?? 0 const DEAD = 0.5 // D-pad (standard buttons 12-15) + left stick. if (pressed(12) || axis(1) < -DEAD) want.add(0) // up if (pressed(15) || axis(0) > DEAD) want.add(1) // right if (pressed(13) || axis(1) > DEAD) want.add(2) // down if (pressed(14) || axis(0) < -DEAD) want.add(3) // left // Face buttons: A(0) bottom, B(1) right, X(2) left, Y(3) top. if (pressed(0)) { want.add(4) // A -> Space / start-equivalent want.add(6) // A also mirrors the "J" primary action button } if (pressed(2)) want.add(5) // X -> I if (pressed(1)) want.add(7) // B -> K if (pressed(3)) want.add(8) // Y -> L // Start(9) / Back(8) -> Escape. if (pressed(9) || pressed(8)) { if (this.onEscape && !this._padEscapeLatch) { this._padEscapeLatch = true this.onEscape() } } else { this._padEscapeLatch = false } // Any pad activity is a user gesture — unblock suspended audio. if (want.size > 0 && this._audioCtx && this._audioCtx.state === "suspended") void this._audioCtx.resume() } // Press newly-active codes. for (const code of want) { if (!this._padHeld.has(code)) { this._padHeld.add(code) this._pendingRelease.delete(code) if (!this._inputState.key_presses.includes(code)) { this._inputState.key_presses.push(code) this._readSincePress.delete(code) } } } // Release codes the pad no longer holds (unless the keyboard holds them too // — but keyboard/pad share codes, so mirror the keyup deferral logic). for (const code of Array.from(this._padHeld)) { if (!want.has(code)) { this._padHeld.delete(code) if (this._readSincePress.has(code)) this._removeKey(code) else this._pendingRelease.add(code) } } } _padEscapeLatch = false constructor(canvas: HTMLCanvasElement) { this._canvas = canvas canvas.width = GRID_W * CELL canvas.height = GRID_H * CELL const ctx = canvas.getContext("2d") if (!ctx) throw new Error("2D canvas context unavailable") this._ctx = ctx } // ImageList replacements clear existing sprites/texts. _clearGameNodes(): void { this._sprites = [] this._texts = [] } // Lazily create the AudioContext and (re)resume it. Browsers start it // suspended until a user gesture, so this is also called from _keyDown. _ensureAudio(): void { if (!this._audioCtx) { const AC: typeof AudioContext = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext this._audioCtx = new AC() this._masterGain = this._audioCtx.createGain() this._masterGain.gain.value = 1 this._masterGain.connect(this._audioCtx.destination) } if (this._audioCtx.state === "suspended") void this._audioCtx.resume() } _loadSounds(entries: string[]): void { this._sounds = entries.map((url) => ({ url, buffer: null, pending: new Set() })) this._sounds.forEach((snd) => { fetch(snd.url) .then((r) => r.arrayBuffer()) .then((buf) => { this._ensureAudio() return this._audioCtx!.decodeAudioData(buf) }) .then((decoded) => { snd.buffer = decoded // Fire any channels that were waiting on this clip to decode. for (const ch of Array.from(snd.pending)) { snd.pending.delete(ch) if (ch.playmode !== STOP) ch._playBuffer(decoded) } }) .catch((err) => { console.log("[v0] coccoon sound load failed:", snd.url, err) }) }) } _stopAllChannels(): void { for (const ch of this._channels) ch._stopSource() this._channels = [] } _loadImages(entries: ImageEntry[]): void { // Cache one HTMLImageElement per unique src so a spritesheet shared by many // tile entries is fetched and decoded only once. const cache = new Map() const load = (src: string): HTMLImageElement => { let el = cache.get(src) if (!el) { el = new Image() el.crossOrigin = "anonymous" el.src = src cache.set(src, el) } return el } this._images = entries.map((entry) => { if (typeof entry === "string") { return { kind: "image", el: load(entry) } as LoadedImage } if ("src" in entry) { return { kind: "tile", el: load(entry.src), sx: entry.sx, sy: entry.sy, sw: entry.sw, sh: entry.sh, } as LoadedImage } return { kind: "color", css: toCss(entry) } as LoadedImage }) } // Mirrors coccoon.update(): the current input state, once per frame. update(): InputState { const snapshot: InputState = { key_presses: this._inputState.key_presses.slice(), clicks: this._inputState.clicks.slice(), } // Mark every currently-held key as observed by this frame. for (const code of snapshot.key_presses) this._readSincePress.add(code) // Drop keys that were released before being observed, now that this frame // has seen them once. for (const code of Array.from(this._pendingRelease)) this._removeKey(code) return snapshot } start(game: Game): void { this._game = game game.ready(this) this._running = true this._lastTime = performance.now() this._accum = 0 window.addEventListener("keydown", this._keyDown) window.addEventListener("keyup", this._keyUp) const loop = (now: number) => { if (!this._running) return const delta = (now - this._lastTime) / 1000 this._lastTime = now this._accum += delta const step = 1 / FPS // Poll gamepads once per frame before stepping the game. this._pollGamepad() // Fixed-timestep update to mirror Godot's capped max_fps. let ran = false while (this._accum >= step) { this._game?.process(step, this) this._accum -= step ran = true } if (ran) this._render() this._raf = requestAnimationFrame(loop) } this._render() this._raf = requestAnimationFrame(loop) } stop(): void { this._running = false cancelAnimationFrame(this._raf) window.removeEventListener("keydown", this._keyDown) window.removeEventListener("keyup", this._keyUp) this._stopAllChannels() if (this._audioCtx) { void this._audioCtx.close() this._audioCtx = null this._masterGain = null } } _render(): void { const ctx = this._ctx ctx.fillStyle = "#000000" ctx.fillRect(0, 0, this._canvas.width, this._canvas.height) // Sprites sorted by z (stable), then texts on top. const ordered = this._sprites .map((s, i) => ({ s, i })) .sort((a, b) => (a.s.z === b.s.z ? a.i - b.i : a.s.z - b.s.z)) for (const { s } of ordered) s._draw(ctx) for (const t of this._texts) t._draw(ctx) } } ``` ## lib/micromoth.ts — the quantum simulator MicroQiskit/MicroMoth: a tiny statevector simulator. Import { MicroMoth } from "@/lib/micromoth" to build circuits and get probabilities in-browser (no network, no API key). ```ts // MicroMoth — a lightweight statevector quantum circuit simulator. // // Faithful TypeScript port of micromoth.gd from the coccoon engine // (https://github.com/moth-quantum/coccoon). // // (C) Copyright Moth Quantum 2024. (C) Copyright IBM 2023. // Licensed under the Apache License, Version 2.0. // Complex amplitudes are represented as [real, imag] pairs, exactly as in // the original GDScript implementation. export type Complex = [number, number] export type Statevector = Complex[] const R2 = 0.70710678118 // 1/sqrt(2) type Gate = | ["init", (number | Complex)[]] | ["x", number] | ["h", number] | ["rx", number, number] | ["rz", number, number] | ["cx", number, number] | ["crx", number, number, number] | ["swap", number, number] | ["m", number, number] export type SimulateMode = "counts" | "statevector" | "probabilities_dict" | "memory" export class QuantumCircuit { numQubits: number numClbits: number name = "" data: Gate[] = [] constructor(n: number, m = 0) { this.numQubits = n this.numClbits = m } initialize(k: (number | Complex)[]): void { this.data = [] this.data.push(["init", k.slice()]) } x(q: number): void { this.data.push(["x", q]) } rx(theta: number, q: number): void { this.data.push(["rx", theta, q]) } rz(theta: number, q: number): void { this.data.push(["rz", theta, q]) } h(q: number): void { this.data.push(["h", q]) } cx(s: number, t: number): void { this.data.push(["cx", s, t]) } crx(theta: number, s: number, t: number): void { this.data.push(["crx", theta, s, t]) } swap(s: number, t: number): void { this.data.push(["swap", s, t]) } measure(q: number, b: number): void { if (b >= this.numClbits) throw new Error("Index for output bit out of range.") if (q >= this.numQubits) throw new Error("Index for qubit out of range.") this.data.push(["m", q, b]) } measureAll(): void { if (this.numClbits === 0) this.numClbits = this.numQubits for (let q = 0; q < this.numQubits; q++) this.measure(q, q) } ry(theta: number, q: number): void { this.rx(Math.PI / 2.0, q) this.rz(theta, q) this.rx(-Math.PI / 2.0, q) } z(q: number): void { this.rz(Math.PI, q) } t(q: number): void { this.rz(Math.PI / 4.0, q) } y(q: number): void { this.rz(Math.PI, q) this.x(q) } } function superpose(x: Complex, y: Complex): [Complex, Complex] { return [ [R2 * (x[0] + y[0]), R2 * (x[1] + y[1])], [R2 * (x[0] - y[0]), R2 * (x[1] - y[1])], ] } function turn(x: Complex, y: Complex, theta: number): [Complex, Complex] { const c = Math.cos(theta / 2.0) const s = Math.sin(theta / 2.0) return [ [x[0] * c + y[1] * s, x[1] * c - y[0] * s], [y[0] * c + x[1] * s, y[1] * c - x[0] * s], ] } function phaseturn(x: Complex, y: Complex, theta: number): [Complex, Complex] { const c = Math.cos(theta / 2.0) const s = Math.sin(theta / 2.0) return [ [x[0] * c + x[1] * s, x[1] * c - x[0] * s], [y[0] * c - y[1] * s, y[1] * c + y[0] * s], ] } function intToBitstring(j: number, nBits: number): string { let result = "" for (let i = nBits - 1; i >= 0; i--) { result += (j >> i) & 1 ? "1" : "0" } return result } export type CountsResult = Record export type ProbabilitiesResult = Record export type SimulateResult = Statevector | ProbabilitiesResult | CountsResult | string[] export function simulate( qc: QuantumCircuit, shots = 1024, get: SimulateMode = "counts", noiseModel: number[] = [], ): SimulateResult { // Initialize statevector: complex numbers as [real, imag] let k: Statevector = [] for (let i = 0; i < 1 << qc.numQubits; i++) k.push([0.0, 0.0]) k[0] = [1.0, 0.0] // Expand scalar noise model to per-qubit list let nm: number[] = noiseModel.slice() if (nm.length === 1) { nm = [] for (let i = 0; i < qc.numQubits; i++) nm.push(noiseModel[0]) } const outputMap: Record = {} for (const gate of qc.data) { if (gate[0] === "init") { const initState = gate[1] if (initState.length > 0 && Array.isArray(initState[0])) { k = (initState as Complex[]).map((e) => [e[0], e[1]]) } else { k = (initState as number[]).map((e) => [Number(e), 0.0]) } } else if (gate[0] === "m") { outputMap[gate[2]] = gate[1] } else if (gate[0] === "x" || gate[0] === "h" || gate[0] === "rx" || gate[0] === "rz") { const j = gate[gate.length - 1] as number for (let i0 = 0; i0 < 1 << j; i0++) { for (let i1 = 0; i1 < 1 << (qc.numQubits - j - 1); i1++) { const b0 = i0 + (1 << (j + 1)) * i1 const b1 = b0 + (1 << j) if (gate[0] === "x") { const tmp = k[b0] k[b0] = k[b1] k[b1] = tmp } else if (gate[0] === "h") { const result = superpose(k[b0], k[b1]) k[b0] = result[0] k[b1] = result[1] } else if (gate[0] === "rx") { const result = turn(k[b0], k[b1], Number(gate[1])) k[b0] = result[0] k[b1] = result[1] } else if (gate[0] === "rz") { const result = phaseturn(k[b0], k[b1], Number(gate[1])) k[b0] = result[0] k[b1] = result[1] } } } } else if (gate[0] === "cx" || gate[0] === "crx" || gate[0] === "swap") { let s: number let t: number let theta = 0.0 if (gate[0] === "crx") { theta = Number(gate[1]) s = gate[2] t = gate[3] } else { s = gate[1] t = gate[2] } const l = Math.min(s, t) const h = Math.max(s, t) for (let i0 = 0; i0 < 1 << l; i0++) { for (let i1 = 0; i1 < 1 << (h - l - 1); i1++) { for (let i2 = 0; i2 < 1 << (qc.numQubits - h - 1); i2++) { const b00 = i0 + (1 << (l + 1)) * i1 + (1 << (h + 1)) * i2 const b01 = b00 + (1 << t) const b10 = b00 + (1 << s) const b11 = b10 + (1 << t) if (gate[0] === "cx") { const tmp = k[b10] k[b10] = k[b11] k[b11] = tmp } else if (gate[0] === "crx") { const result = turn(k[b10], k[b11], theta) k[b10] = result[0] k[b11] = result[1] } else if (gate[0] === "swap") { const tmp = k[b01] k[b01] = k[b10] k[b10] = tmp } } } } } } if (get === "statevector") return k // Compute probabilities from statevector const probs: number[] = k.map((e) => e[0] * e[0] + e[1] * e[1]) // Apply noise model if present if (nm.length > 0) { for (let j = 0; j < qc.numQubits; j++) { const pMeas = Number(nm[j]) for (let i0 = 0; i0 < 1 << j; i0++) { for (let i1 = 0; i1 < 1 << (qc.numQubits - j - 1); i1++) { const b0 = i0 + (1 << (j + 1)) * i1 const b1 = b0 + (1 << j) const p0 = probs[b0] const p1 = probs[b1] probs[b0] = (1.0 - pMeas) * p0 + pMeas * p1 probs[b1] = (1.0 - pMeas) * p1 + pMeas * p0 } } } } if (get === "probabilities_dict") { const result: ProbabilitiesResult = {} for (let j = 0; j < probs.length; j++) { result[intToBitstring(j, qc.numQubits)] = probs[j] } return result } // Sampling for counts/memory const m: string[] = [] for (let shot = 0; shot < shots; shot++) { let cumu = 0.0 const r = Math.random() for (let j = 0; j < probs.length; j++) { cumu += probs[j] if (r < cumu) { const rawOut = intToBitstring(j, qc.numQubits) const outList: string[] = [] for (let i = 0; i < qc.numClbits; i++) outList.push("0") for (const bit in outputMap) { const b = Number(bit) outList[qc.numClbits - 1 - b] = rawOut[qc.numQubits - 1 - outputMap[b]] } m.push(outList.join("")) break } } } if (get === "memory") return m const counts: CountsResult = {} for (const out of m) { counts[out] = (counts[out] ?? 0) + 1 } return counts } export const MicroMoth = { QuantumCircuit, simulate, R2 } export default MicroMoth ``` ## lib/games/qubit-park.ts — demo (start here) The gentlest demo. Shows the exact cartridge shape: import primitives, export a class implementing Game with ready() and process(). Uses MicroMoth locally. ```ts // ============================================================================ // QUBIT PARK — a tutorial introduction to the coccoon game engine. // ============================================================================ // // This is a web port of games/qubit_park/qubit_park.gd. The quantum logic is // unchanged from the original: every terrain tile is chosen by a single-qubit // circuit whose rotation angles depend on the tile's world position. Only the // engine calls are adapted to coccoon's TypeScript API. // // Read this file top to bottom — the comments walk you through everything you // need to build your own coccoon game. // // ---------------------------------------------------------------------------- // STEP 1: Import the tools you need from coccoon. // ---------------------------------------------------------------------------- // Everything the engine gives you lives in "@/lib/coccoon". You pull in only // the pieces you use: // - Coccoon the engine instance (the "console"). Handed to you in ready(). // - GRID_W the screen width in tiles (32). The playfield is a fixed grid. // - GRID_H the screen height in tiles (18). // - ImageList registers the images your game can draw, in order (see STEP 3). // - Sprite a single drawable cell on the grid that shows one image. // - Text an on-screen text box (used here for the title card). // - color() builds an RGBA color; Colors is a set of named presets. // - Game the interface your game class implements (ready + process). import { Coccoon, GRID_W, GRID_H, ImageList, Sprite, Text, color, Colors, type Game } from "@/lib/coccoon" // ---------------------------------------------------------------------------- // STEP 2: For fast simulation of simple quantum things, import MicroMoth. // ---------------------------------------------------------------------------- // MicroMoth is a tiny statevector simulator that runs entirely in the browser. // It's perfect for small circuits (a few qubits) that you want to evaluate // instantly, every frame, with no network call. Build a QuantumCircuit, apply // gates, then simulate() to read out probabilities. (For heavier quantum work // you would instead call the Moth platform — see Quantum Caverns for that.) import { MicroMoth } from "@/lib/micromoth" const TERRAIN_TYPES = 6 // ---------------------------------------------------------------------------- // STEP 3: Import your images. You refer to them later by their INDEX. // ---------------------------------------------------------------------------- // The order of this list defines each image's id: the first is 0, the next is // 1, then 2, and so on. A Sprite doesn't hold a file path — it holds an image // id (an integer) that points into this list. So a sprite showing "grass" is // really just a sprite whose image_id is 2, because grass is the 3rd entry. // // Keep the indices in mind — _getImageId() below returns one of these numbers, // and that number is assigned straight to a sprite's image_id. const IMAGE_PATHS = [ "/images/terrain-water.png", // 0 water "/images/terrain-red-flower.png", // 1 red flower "/images/terrain-grass.png", // 2 grass "/images/terrain-path.png", // 3 path "/images/terrain-grass.png", // 4 grass again (listed twice for convenience) "/images/terrain-purple-flower.png", // 5 purple flower "/images/terrain-tree.png", // 6 tree ] // ---------------------------------------------------------------------------- // STEP 4: Write your game as a class that implements Game. // ---------------------------------------------------------------------------- // The Game interface requires two methods: // ready(engine) called once, at startup. Do all your setup here. // process(delta, engine) called once per frame. Do input + logic + drawing. // Anything your game needs to remember between frames lives as a field. export class QubitPark implements Game { private _engine!: Coccoon // We keep one Sprite per screen cell, keyed by "dx,dy" grid coordinates. // Scrolling the world never moves these sprites — it just swaps their // image_id (see _refreshTerrain). This is the core coccoon trick: a fixed // grid of sprites whose images change. private _sprites: Record = {} // The world offset. As the player scrolls, (posX, posY) is the world // coordinate that currently sits in the top-left cell of the screen. private _posX = 0 private _posY = 0 // Six random "seed" values that shape the quantum rotations, so every run // produces a different landscape. private _s: number[] = [] private _title = true private _titleText!: Text private _infoText!: Text // Input in coccoon is polled per frame (see STEP 6). To detect a key that // was *just* pressed this frame, we remember which keys were down last frame. private _prevKeys: number[] = [] // The title background is painted one row per frame so the title card stays // responsive instead of freezing while all 32x18 tiles are simulated. private _titleGenRow = 0 private _titleGenDone = false // -------------------------------------------------------------------------- // ready(): one-time setup. The engine passes you the Coccoon instance. // -------------------------------------------------------------------------- ready(engine: Coccoon): void { this._engine = engine // Register the images. ImageList maps the integer indices from STEP 3 to // the actual image files. After this, image_id 0 means water, 2 means // grass, and so on. You only need to do this once. new ImageList(engine, IMAGE_PATHS) // Six seed values — one per rotation parameter used in _getImageId(). this._s = [] for (let i = 0; i < 6; i++) this._s.push(0.5 * Math.random()) // Create the grid of sprites. new Sprite(engine, imageId, gridX, gridY, z): // imageId which image to show initially (2 = grass here) // gridX column, 0 .. GRID_W-1 // gridY row, 0 .. GRID_H-1 // z draw order (higher draws on top) // We make one sprite for every cell on screen and store it by coordinate. for (let dx = 0; dx < GRID_W; dx++) { for (let dy = 0; dy < GRID_H; dy++) { this._sprites[`${dx},${dy}`] = new Sprite(engine, 2, dx, dy, 0) } } // A Text box for the title. new Text(engine, string, fontSize, gridX, // gridY, width, height, fontColor, backgroundColor). Coordinates and sizes // are in grid units, matching the sprite grid. this._titleText = new Text(engine, "QUBIT PARK", 26.0, 3.5, 3.0, 12.5, 40, Colors.WHITE, color(0.03, 0.08, 0.03, 0.88)) this._infoText = new Text( engine, "Quantum terrain generator\n\n" + "Every tile is computed from a single-qubit circuit\n" + "whose rotation angles depend on its world position.\n\n" + "Arrow keys / WASD or Space to start\n" + "Esc to menu", 26.0, 6.5, 3.0, 5.0, 15, color(0.75, 1.0, 0.75), color(0.03, 0.08, 0.03, 0.88), ) } // -------------------------------------------------------------------------- // STEP 5: The quantum part. Map a world position to one of six terrain // images using a single-qubit circuit simulated by MicroMoth. // -------------------------------------------------------------------------- private _getImageId(x: number, y: number): number { // Build a circuit with a single qubit. const qc = new MicroMoth.QuantumCircuit(1) // Turn the world position (and our random seeds) into three rotation // angles. Because the angles are a smooth function of x and y, neighbouring // tiles get similar-but-not-identical results — that's what makes the // landscape look continuous rather than pure noise. const tx = ((this._s[0] * x + this._s[1] * y) * Math.PI) / 7.0 const ty = ((this._s[2] * x - this._s[3] * y) * Math.PI) / 7.0 const tz = ((this._s[4] * (x + y) + this._s[5] * (x - y)) * Math.PI) / 7.0 // Apply rotation gates around the X, Z and Y axes of the Bloch sphere. qc.rx(tx, 0) qc.rz(tz, 0) qc.ry(ty, 0) // Simulate and read the probability of measuring |0>. simulate() with // "probabilities_dict" returns a map like { "0": 0.7, "1": 0.3 }. const probs = MicroMoth.simulate(qc, 1024, "probabilities_dict") as Record // Scale P(|0>) in [0,1] to an image index in [0, TERRAIN_TYPES-1]. This is // the number we hand straight to a sprite's image_id (see STEP 3). return Math.round((probs["0"] ?? 0.0) * (TERRAIN_TYPES - 1)) } // Recompute every on-screen tile for the current world offset. Note we don't // move sprites — we only reassign image_id on the sprites that already exist. private _refreshTerrain(): void { for (let dx = 0; dx < GRID_W; dx++) { for (let dy = 0; dy < GRID_H; dy++) { this._sprites[`${dx},${dy}`].image_id = this._getImageId(this._posX + dx, this._posY + dy) } } } // -------------------------------------------------------------------------- // STEP 6: process(): runs every frame. Read input, update state, draw. // -------------------------------------------------------------------------- process(_delta: number, engine: Coccoon): void { // Paint the title-screen terrain one row per frame so the title stays // responsive instead of blocking on all 18 rows of simulation at once. if (!this._titleGenDone && this._titleGenRow < GRID_H) { const dy = this._titleGenRow for (let dx = 0; dx < GRID_W; dx++) { this._sprites[`${dx},${dy}`].image_id = this._getImageId(dx, dy) } this._titleGenRow++ if (this._titleGenRow >= GRID_H) this._titleGenDone = true } // engine.update() advances the engine and returns this frame's input. // inp.key_presses is an array of the direction/action keys held THIS frame. // Direction codes come from either the arrow keys or WASD (synonymous): // 0 = up, 1 = right, 2 = down, 3 = left, 4 = start/space. const inp = engine.update() const keys = inp.key_presses // Derive "just pressed this frame" by diffing against last frame's keys. const justPressed: number[] = [] for (const k of keys) if (!this._prevKeys.includes(k)) justPressed.push(k) this._prevKeys = keys.slice() // While the title card is up, any key press starts the game: hide the text // (by making it transparent) and make sure the terrain is generated. if (this._title) { if ([0, 1, 2, 3, 4].some((k) => justPressed.includes(k))) { this._title = false for (const t of [this._titleText, this._infoText]) { t.set_font_color(color(0, 0, 0, 0)) t.set_background_color(color(0, 0, 0, 0)) } if (!this._titleGenDone) this._refreshTerrain() } return } // Gameplay: held direction keys scroll the world. We only re-simulate the // terrain when the offset actually changed, to avoid needless work. let moved = false if (keys.includes(0)) { this._posY += 1 moved = true } if (keys.includes(1)) { this._posX += 1 moved = true } if (keys.includes(2)) { this._posY -= 1 moved = true } if (keys.includes(3)) { this._posX -= 1 moved = true } if (moved) this._refreshTerrain() } } ``` ## lib/games/quantum-caverns.ts — demo (Atlas API + audio) Submits a real quantum job to the Atlas platform (see /api/moth-blur), turns the result into a maze, and plays looping music via the audio primitives. ```ts // Quantum Caverns — a quantum maze game, and a tutorial for calling the Moth // platform from a coccoon game. // // Web port of games/quantum_caverns/quantum_caverns.gd. Navigate from the red // start to the blue exit within the step limit; your previous route stays // visible as a dim trail. The maze itself is generated by quantum blur: a // height map is scattered with random peaks, then blurred by rotating each // qubit on the Bloch sphere. Cells above 0.5 become walls; the rest paths. // // The blur is computed by the Moth platform's blur-core-v1 engine (see the // "── API" methods). This game has NO local fallback: it requires an Atlas API // key and generates every maze on the platform. Without a key it stays on the // title screen and asks for one (auto-generating as soon as a key is set); if // the platform is unreachable it shows an error and retries on Space. // // The original uses fire-and-forget GDScript coroutines guarded by flags; this // port uses async methods guarded by the same flags, checked each frame in // process(). JS is single-threaded, so the flags stay consistent. import { Coccoon, ImageList, Sprite, Text, SoundList, Sound, LOOP, color, Colors, type Game } from "@/lib/coccoon" import { posKey, logNormalizeHeight, type HeightMap } from "@/lib/quantumblur" const VIEW_W = 32 const VIEW_H = 17 const L = 32 const IMG_PATH = 0 const IMG_WALL = 1 const IMG_END = 2 const IMG_PLAYER = 3 const IMG_OUTSIDE = 4 const IMG_PATH_DIM = 5 type Via = "moth" | "pending" | "needs-key" | "error" type BlockReason = "needs-key" | "error" | null type Vec = { x: number; y: number } const DIRS: Vec[] = [ { x: 1, y: 0 }, { x: -1, y: 0 }, { x: 0, y: 1 }, { x: 0, y: -1 }, ] export class QuantumCaverns implements Game { private _engine!: Coccoon private _getApiKey: () => string private _onStatus?: (via: Via) => void private _tiles: Record = {} private _music?: Sound private _statusText!: Text private _titleText!: Text private _loadingText!: Text private _maze: Record = {} private _start: Vec = { x: 0, y: 0 } private _end: Vec = { x: 0, y: 0 } private _maxSteps = 0 private _player: Vec = { x: 0, y: 0 } private _step = 0 private _path: Vec[] = [] private _lastpath: Vec[] = [] private _success = false private _prevKeys: number[] = [] private _generating = false private _blockReason: BlockReason = null private _started = false private _animTick = 0 private _prefetching = false private _nextMazeHeight: HeightMap = {} private _nextMazeReady = false constructor(getApiKey: () => string, onStatus?: (via: Via) => void) { this._getApiKey = getApiKey this._onStatus = onStatus } ready(engine: Coccoon): void { this._engine = engine // Ambient theme, generated by the Moth platform's retrocausal-echo-v1 // engine: a short seed motif fed through a quantum-measured multi-tap delay, // whose taps invert/reverse/rotate the signal into an evolving cave drone. // Loops quietly under the game; audio unblocks on the player's first keypress // (browsers keep it suspended until a user gesture). new SoundList(engine, ["/audio/caverns-theme.wav"]) this._music = new Sound(engine, 0, LOOP, 0.5) new ImageList(engine, [ color(0.15, 0.5, 0.15), // 0 path color(0.6, 0.3, 0.0), // 1 wall color(0.1, 0.25, 0.9), // 2 exit color(0.9, 0.1, 0.1), // 3 player color(0.15, 0.15, 0.15), // 4 outside maze color(0.05, 0.22, 0.05), // 5 path dim (last loop trail) ]) for (let dx = 0; dx < VIEW_W; dx++) { for (let sy = 0; sy < VIEW_H; sy++) { this._tiles[posKey(dx, sy)] = new Sprite(engine, IMG_OUTSIDE, dx, VIEW_H - sy, 0) } } this._statusText = new Text( engine, "Contacting the Moth platform", VIEW_W, 2, 0, 0, 28, Colors.WHITE, color(0.05, 0.05, 0.15), ) this._setupTitleTiles() this._titleText = new Text( engine, "QUANTUM CAVERNS", 26.0, 3.0, 3.0, 12.5, 40, Colors.WHITE, color(0.03, 0.05, 0.08, 0.88), ) this._loadingText = new Text( engine, "Navigate from start (red) to exit (blue)\nwithin the step limit.\n\n" + "Your previous route stays visible as a trail.\n\n" + "Arrow keys / WASD to move\n" + "Space for a new maze\n" + "Esc to menu", 26.0, 6.5, 3.0, 5.0, 15, color(0.75, 0.8, 1.0), color(0.03, 0.05, 0.08, 0.88), ) void this._startGeneration() } // ── Generation dispatcher ────────────────────────────────────────────────── private async _startGeneration(): Promise { this._generating = true this._blockReason = null this._animTick = 0 this._statusText.text = "Contacting the Moth platform" const height = await this._generateHeight(true) this._generating = false if (!height) { // No key, or the platform call failed. Stay on the title; process() will // retry (auto once a key appears, or on Space for a platform error). this._blockReason = this._getApiKey().length === 0 ? "needs-key" : "error" return } this._buildMaze(height) this._hideTitle() this._resetLoop() void this._prefetchNext() } private async _prefetchNext(): Promise { if (this._prefetching || this._nextMazeReady) return this._prefetching = true // Silent: don't flip the status pill while the player is mid-maze. const height = await this._generateHeight(false) this._prefetching = false if (height) { this._nextMazeHeight = height this._nextMazeReady = true } } private _applyNextMaze(): void { this._buildMaze(this._nextMazeHeight) this._nextMazeHeight = {} this._nextMazeReady = false this._resetLoop() void this._prefetchNext() } private _hideTitle(): void { for (const t of [this._titleText, this._loadingText]) { t.set_font_color(color(0, 0, 0, 0)) t.set_background_color(color(0, 0, 0, 0)) } this._started = true } // ── Title screen tiles ────────────────────────────────────────────────────── // // A seeded pattern plus one cellular-automaton pass clusters walls into // organic blobs while the first maze generates, so the loading screen looks // like a piece of the world rather than a blank slate. private _setupTitleTiles(): void { let seed = 42 const rand = () => { // deterministic LCG so the title looks identical every load seed = (seed * 1103515245 + 12345) & 0x7fffffff return seed / 0x7fffffff } const raw: boolean[][] = [] for (let dx = 0; dx < VIEW_W; dx++) { const col: boolean[] = [] for (let sy = 0; sy < VIEW_H; sy++) col.push(rand() < 0.46) raw.push(col) } for (let dx = 0; dx < VIEW_W; dx++) { for (let sy = 0; sy < VIEW_H; sy++) { const key = posKey(dx, sy) if (dx === 0 || dx === VIEW_W - 1 || sy === 0 || sy === VIEW_H - 1) { this._tiles[key].image_id = IMG_WALL continue } let walls = 0 for (const d of DIRS) { const nx = dx + d.x const ny = sy + d.y if (nx < 0 || nx >= VIEW_W || ny < 0 || ny >= VIEW_H) walls += 1 else if (raw[nx][ny]) walls += 1 } this._tiles[key].image_id = walls >= 2 ? IMG_WALL : IMG_PATH } } this._tiles[posKey(3, 3)].image_id = IMG_PLAYER this._tiles[posKey(28, 13)].image_id = IMG_END } // ── Height generation ──────────────────────────────────────────────────────── // // The only source of a maze is the Moth platform. Without a key we report // "needs-key" and return null so the caller keeps the title up; on a platform // error we report "error" and return null so the caller can retry. private async _generateHeight(report: boolean): Promise { const key = this._getApiKey() if (key.length === 0) { if (report) this._onStatus?.("needs-key") return null } if (report) this._onStatus?.("pending") const height = await this._genHeightApi(key) if (height && Object.keys(height).length > 0) { if (report) this._onStatus?.("moth") return height } if (report) this._onStatus?.("error") return null } private _makeInitialHeight(): HeightMap { const height: HeightMap = {} for (let x = 0; x < L; x++) { for (let y = 0; y < L; y++) height[posKey(x, y)] = 0.0 } for (let i = 0; i < L; i++) { height[posKey(Math.floor(Math.random() * L), Math.floor(Math.random() * L))] = Math.random() } return height } // ── API: blur via the Moth platform ───────────────────────────────────────── // // The platform's engine is asynchronous: we submit a job, then poll it until // it completes (which can take a minute or two). The server proxy owns the // exact HTTP contract; here we drive it with two fast calls — one "submit" to // get a job id, then repeated "poll"s from the browser — so no single request // is held open for the whole job. Any failure resolves to null. private async _genHeightApi(key: string): Promise { const height = this._makeInitialHeight() try { // 1. Submit the initial grid and get a job id back immediately. const submit = await this._post({ action: "submit", values: this._heightToArray(height), strength: 0.25, key, }) if (submit.status === 401 || submit.status === 403) return null // terminal: bad key if (!submit.ok) return null const sd = (await submit.json().catch(() => null)) as { jobId?: string } | null if (!sd?.jobId) return null // 2. Poll until the job completes. The platform can take a couple of // minutes. We poll promptly first, settle to a steady cadence, and // back off on transient failures. Crucially we also wake immediately // when the tab regains focus, so a throttled background timer can't // strand a job that already finished upstream. A run of genuine // failures (or a terminal auth error) aborts instead of spinning for // the whole deadline. const deadline = Date.now() + 4 * 60 * 1000 const steadyDelay = 2000 const maxDelay = 8000 let delay = 800 let consecutiveErrors = 0 const maxConsecutiveErrors = 15 while (Date.now() < deadline) { await this._waitBeforePoll(delay) let poll: Response try { poll = await this._post({ action: "poll", jobId: sd.jobId, key }) } catch { if (++consecutiveErrors > maxConsecutiveErrors) return null delay = Math.min(maxDelay, Math.max(steadyDelay, delay) * 2) continue } // Terminal auth failure: stop immediately rather than looping blind. if (poll.status === 401 || poll.status === 403) return null if (!poll.ok) { if (++consecutiveErrors > maxConsecutiveErrors) return null delay = Math.min(maxDelay, Math.max(steadyDelay, delay) * 2) continue } const pd = (await poll.json().catch(() => null)) as { status?: string; output?: unknown } | null if (!pd?.status) { if (++consecutiveErrors > maxConsecutiveErrors) return null delay = Math.min(maxDelay, Math.max(steadyDelay, delay) * 2) continue } consecutiveErrors = 0 if (pd.status === "completed") return this._extractHeight(pd.output) if (pd.status === "failed" || pd.status === "error" || pd.status === "cancelled") return null // still queued / running delay = steadyDelay } return null } catch { return null } } // Single uncached POST to the job proxy. POST bodies are never cached by the // browser, but we set no-store explicitly so job status can never be served // stale. private _post(body: unknown): Promise { return fetch("/api/moth-blur", { method: "POST", headers: { "Content-Type": "application/json" }, cache: "no-store", body: JSON.stringify(body), }) } // Resolve after `ms`, OR immediately when the tab becomes visible again. // Background tabs have their timers throttled, so without this a poll can be // delayed long after the job is done; waking on refocus makes the game notice // completion the instant the player returns to the tab. private _waitBeforePoll(ms: number): Promise { return new Promise((resolve) => { let settled = false const finish = () => { if (settled) return settled = true clearTimeout(timer) if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible) resolve() } const onVisible = () => { if (typeof document !== "undefined" && document.visibilityState === "visible") finish() } const timer = setTimeout(finish, ms) if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible) }) } // ── Array <-> height map conversion ───────────────────────────────────────── private _heightToArray(height: HeightMap): number[][] { const arr: number[][] = [] for (let y = 0; y < L; y++) { const row: number[] = [] for (let x = 0; x < L; x++) row.push(height[posKey(x, y)]) arr.push(row) } return arr } private _extractHeight(data: unknown): HeightMap | null { let arr: unknown = null if (Array.isArray(data)) { arr = data } else if (data && typeof data === "object") { const obj = data as Record arr = obj.values ?? obj.result ?? obj.output ?? null } if (!Array.isArray(arr) || arr.length === 0) return null const height: HeightMap = {} const rows = Math.min(L, arr.length) for (let y = 0; y < rows; y++) { const row = arr[y] if (!Array.isArray(row)) continue const cols = Math.min(L, row.length) for (let x = 0; x < cols; x++) height[posKey(x, y)] = Number(row[x]) } // We start from a mostly-zero field with a few scattered peaks, so the // blurred result the platform returns is a smooth probability field: a // handful of small peaks (max ~0.9) with a long near-zero tail. Only ~2% of // cells sit above 0.5, so thresholding the raw values gives an almost empty // cave. QuantumBlur's own decoder solves this with circuit2height(..., // useLog=true): it divides by the max, then remaps every cell on a log scale // keyed to the smallest non-zero value, spreading the heights across [0,1] // so ~half cross 0.5. We apply that identical transform to the platform // output here, so the online maze looks like the local-simulator one. return logNormalizeHeight(height) } // ── Maze construction from height map ──────────────────────────────────────── private _buildMaze(newHeight: HeightMap): void { this._maze = {} for (let x = 0; x < L; x++) { for (let y = 0; y < L; y++) { const key = posKey(x, y) if (x === 0 || x === L - 1 || y === 0 || y === L - 1) { this._maze[key] = 1 } else { this._maze[key] = (newHeight[key] ?? 1.0) > 0.5 ? 1 : 0 } } } const pathCells: Vec[] = [] for (let x = 0; x < L; x++) { for (let y = 0; y < L; y++) { if (this._maze[posKey(x, y)] === 0) pathCells.push({ x, y }) } } const largest = this._largestComponent(pathCells) const largestSet = new Set(largest.map((p) => posKey(p.x, p.y))) for (const p of pathCells) { if (!largestSet.has(posKey(p.x, p.y))) this._maze[posKey(p.x, p.y)] = 1 } const r1 = this._bfs(largest[0], largestSet) const r2 = this._bfs(r1.farthest, largestSet) const r3 = this._bfs(r2.farthest, largestSet) this._start = r2.farthest this._end = r3.farthest this._maxSteps = r3.dist + 2 } // ── Graph helpers ──────────────────────────────────────────────────────────── private _bfs(from: Vec, valid: Set): { farthest: Vec; dist: number } { const dist: Record = { [posKey(from.x, from.y)]: 0 } const queue: Vec[] = [from] let head = 0 let farthest = from while (head < queue.length) { const pos = queue[head] head += 1 for (const d of DIRS) { const nxt = { x: pos.x + d.x, y: pos.y + d.y } const nk = posKey(nxt.x, nxt.y) if (valid.has(nk) && !(nk in dist)) { dist[nk] = dist[posKey(pos.x, pos.y)] + 1 queue.push(nxt) if (dist[nk] > dist[posKey(farthest.x, farthest.y)]) farthest = nxt } } } return { farthest, dist: dist[posKey(farthest.x, farthest.y)] } } private _largestComponent(cells: Vec[]): Vec[] { const unvisited = new Set(cells.map((p) => posKey(p.x, p.y))) let largest: Vec[] = [] while (unvisited.size > 0) { const startKey = unvisited.values().next().value as string const [sx, sy] = startKey.split(",").map(Number) const start = { x: sx, y: sy } const visited: Vec[] = [] const queue: Vec[] = [start] const inQ = new Set([startKey]) let head = 0 while (head < queue.length) { const pos = queue[head] head += 1 visited.push(pos) unvisited.delete(posKey(pos.x, pos.y)) for (const d of DIRS) { const nxt = { x: pos.x + d.x, y: pos.y + d.y } const nk = posKey(nxt.x, nxt.y) if (unvisited.has(nk) && !inQ.has(nk)) { inQ.add(nk) queue.push(nxt) } } } if (visited.length > largest.length) largest = visited } return largest } // ── Game loop ──────────────────────────────────���─��─────────────────────────── private _resetLoop(): void { this._player = this._start this._step = 0 this._path = [this._start] this._success = false this._render() } private _scrollY(): number { return Math.max(0, Math.min(this._player.y - Math.floor(VIEW_H / 2), L - VIEW_H)) } private _tileImg(pos: Vec, inLast: boolean): number { const key = posKey(pos.x, pos.y) if (!(key in this._maze)) return IMG_OUTSIDE if (pos.x === this._player.x && pos.y === this._player.y) return IMG_PLAYER if (pos.x === this._end.x && pos.y === this._end.y) return IMG_END if (this._maze[key] === 1) return IMG_WALL return inLast ? IMG_PATH_DIM : IMG_PATH } private _render(): void { const sy = this._scrollY() const lpSet = new Set(this._lastpath.map((p) => posKey(p.x, p.y))) for (let dx = 0; dx < VIEW_W; dx++) { for (let screenY = 0; screenY < VIEW_H; screenY++) { const mazePos = { x: dx, y: sy + screenY } this._tiles[posKey(dx, screenY)].image_id = this._tileImg(mazePos, lpSet.has(posKey(mazePos.x, mazePos.y))) } } if (this._success) { this._statusText.text = "You made it out! Space = new maze." } else { this._statusText.text = `${this._maxSteps - this._step} steps to reach the exit.` } } // Read this frame's input and return the keys that are newly pressed since // the last frame (the "just pressed" diff). Also advances _prevKeys. private _readJustPressed(engine: Coccoon): number[] { const keys = engine.update().key_presses const justPressed: number[] = [] for (const k of keys) if (!this._prevKeys.includes(k)) justPressed.push(k) this._prevKeys = keys.slice() return justPressed } process(_delta: number, engine: Coccoon): void { if (this._generating) { this._animTick += 1 if (this._animTick % 10 === 0) { this._statusText.text = "Contacting the Moth platform" + ".".repeat(Math.floor(this._animTick / 10) % 4) } return } if (this._blockReason) { const justPressed = this._readJustPressed(engine) if (this._blockReason === "needs-key") { const msg = "Add an Atlas API key to generate a maze" this._statusText.text = msg if (!this._started) { this._loadingText.text = "This game generates its maze on the Moth platform.\n\n" + "Add an Atlas API key — from the menu, or on this page —\nto play.\n\n" + "Esc to menu" } // Auto-retry the moment a key becomes available. if (this._getApiKey().length > 0) { this._blockReason = null void this._startGeneration() } return } // platform error const msg = "Couldn't reach the Moth platform — Space to retry" this._statusText.text = msg if (!this._started) { this._loadingText.text = msg + "\n\nCheck your key, then press Space.\n\nEsc to menu" } if (justPressed.includes(4)) { this._blockReason = null void this._startGeneration() } return } const justPressed = this._readJustPressed(engine) if (this._success) { if (justPressed.includes(4)) { this._lastpath = [] // Instant if the next maze was prefetched; otherwise generate one now. if (this._nextMazeReady) this._applyNextMaze() else void this._startGeneration() } return } if (this._step >= this._maxSteps) { this._lastpath = this._path.slice() this._resetLoop() return } // One axis per step, priority order, so a frame that happens to carry two // keys can never force a blocked diagonal move. let dx = 0 let dy = 0 if (justPressed.includes(0)) dy = -1 else if (justPressed.includes(2)) dy = 1 else if (justPressed.includes(3)) dx = -1 else if (justPressed.includes(1)) dx = 1 if (dx !== 0 || dy !== 0) { const nxt = { x: this._player.x + dx, y: this._player.y + dy } if ((this._maze[posKey(nxt.x, nxt.y)] ?? 1) === 0) { this._player = nxt this._step += 1 this._path.push(nxt) if (this._player.x === this._end.x && this._player.y === this._end.y) this._success = true } this._render() } } } ``` ## lib/games/celeste.ts — demo (a full platformer) A complete Celeste Classic port on the same engine: tilemaps, physics, multiple rooms. ```ts // ============================================================================ // CELESTE (Quantum Remix) — a coccoon port of Celeste Classic. // ============================================================================ // // Web port of games/celeste/celeste.gd from the coccoon repo, itself a port of // the Pico-8 original by Maddy Thorson & Noel Berry. The game logic (player // physics, dashing, hair, rooms, entities) is translated verbatim from the // GDScript; only the engine calls are adapted to coccoon's TypeScript API. // // The "quantum remix" is visual: every solid tile has three sprite variants // (original / pair / single) generated with Moth's TESSA tool, and tiles // flicker between them — a nod to a qubit collapsing between measured states. // // The playfield is 16x16 Pico-8 tiles (8px each = 128x128 px) drawn into the // 32x18 coccoon grid at offset (OX, OY), one cell per tile. import { Coccoon, GRID_W, GRID_H, ImageList, Sprite, Text, color, Colors, type Game, type SheetTile } from "@/lib/coccoon" import { CELESTE_MAP_FLAT, CELESTE_GFF } from "@/lib/games/celeste-data" // ── Layout constants (from celeste.gd) ────────────────────────────────────── const L = 16 // playfield size in tiles const OX = 8 // grid x offset of the playfield const OY = 1 // grid y offset of the playfield // Input key codes as surfaced by coccoon.update(). Movement is the d-pad // (arrows or WASD, both synonymous). The face buttons are IJKL: K (jump) and // J (dash). Space/Enter also work as jump so the game is playable one-handed. const K_UP = 0 const K_RIGHT = 1 const K_DOWN = 2 const K_LEFT = 3 const K_SPACE = 4 const K_DASH = 6 // J button const K_JUMP = 7 // K button // Image ids. 0..127 are the original sprite tiles; the block after that holds // solid-colour helpers and the two flicker variant banks. const IMG_CLEAR = 128 const IMG_BG = 129 const IMG_HAIR_DASH = 130 // red — dash available const IMG_HAIR_NODASH = 131 // blue — dash spent const IMG_TITLE = 132 // unused visually (title screen is a room + text) const IMG_SNOW_W = 133 const IMG_SNOW_G = 134 const IMG_BLACK = 135 const PAIR_OFFSET = 136 // 136..263 — "pair" flicker variant of tile 0..127 const SINGLE_OFFSET = 264 // 264..391 — "single" flicker variant of tile 0..127 const MAX_DJUMP = 1 const SNOW_N = 25 const TAU = Math.PI * 2 // ── Small helpers matching GDScript built-ins ─────────────────────────────── const floori = (v: number) => Math.floor(v) const fmod = (a: number, b: number) => a % b function appr(val: number, target: number, amount: number): number { return val < target ? Math.min(val + amount, target) : Math.max(val - amount, target) } function sgn(v: number): number { if (v > 0) return 1 if (v < 0) return -1 return 0 } type Vec = { x: number; y: number } type Hitbox = { x: number; y: number; w: number; h: number } // A game object is a loosely-typed record, mirroring the GDScript Dictionary // objects. Only the player uses the full field set; entities use a subset. type GObj = Record export class Celeste implements Game { private _engine!: Coccoon private _tiles: Sprite[][] = [] // [cx][cy] background/tile sprites private _entitySprs: Sprite[] = [] private _hairSprs: Sprite[] = [] private _playerSpr!: Sprite private _titleText!: Text private _statusText!: Text private _snowSprs: Sprite[] = [] private _snowX: number[] = [] private _snowY: number[] = [] private _snowSpd: number[] = [] private _snowOff: number[] = [] // Vector2i(cx,cy) -> canonical tile id (0..127) for tiles that can flicker. private _tileCanvas = new Map() private _roomX = 0 private _roomY = 0 private _objects: GObj[] = [] private _deaths = 0 private _shake = 0 private _freeze = 0 private _hasDashed = false private _prevKeys: number[] = [] private _willRestart = false private _delayRestart = 0 private _title = true // ── Setup ───────────────────────────────────────────────────────────────── ready(engine: Coccoon): void { this._engine = engine const sheet = (src: string, i: number): SheetTile => ({ src, sx: (i % 16) * 8, sy: Math.floor(i / 16) * 8, sw: 8, sh: 8, }) const entries: (string | SheetTile | ReturnType)[] = [] for (let i = 0; i < 128; i++) entries.push(sheet("/sprites/celeste-original.png", i)) // 0..127 entries.push(color(0, 0, 0, 0)) // 128 IMG_CLEAR entries.push(color(0.05, 0.05, 0.15)) // 129 IMG_BG entries.push(color(1.0, 0.0, 0.302)) // 130 IMG_HAIR_DASH entries.push(color(0.161, 0.678, 1.0)) // 131 IMG_HAIR_NODASH entries.push(color(0, 0, 0, 0)) // 132 IMG_TITLE (unused) entries.push(Colors.WHITE) // 133 IMG_SNOW_W entries.push(color(0.53, 0.53, 0.53)) // 134 IMG_SNOW_G entries.push(Colors.BLACK) // 135 IMG_BLACK for (let i = 0; i < 128; i++) entries.push(sheet("/sprites/celeste-pair.png", i)) // 136..263 for (let i = 0; i < 128; i++) entries.push(sheet("/sprites/celeste-single.png", i)) // 264..391 new ImageList(engine, entries) // Background tile grid (one sprite per screen cell). for (let cx = 0; cx < GRID_W; cx++) { this._tiles[cx] = [] for (let cy = 0; cy < GRID_H; cy++) { this._tiles[cx][cy] = new Sprite(engine, IMG_BG, cx, cy, 0) } } // Entity sprite pool, hair sprites, player sprite (drawn above tiles). for (let i = 0; i < 40; i++) this._entitySprs.push(new Sprite(engine, IMG_CLEAR, 0, 0, 1)) for (let i = 0; i < 5; i++) this._hairSprs.push(new Sprite(engine, IMG_CLEAR, 0, 0, 2, 0.125)) this._playerSpr = new Sprite(engine, IMG_CLEAR, 0, 0, 3) this._statusText = new Text(engine, "", GRID_W, 1, 0, 0, 16, Colors.WHITE, color(0.05, 0.05, 0.15)) this._titleText = new Text(engine, "", 28, 7, 2, 0, 20, color(0, 0, 0, 0), color(0, 0, 0, 0)) this._initSnow() this._showTitle() } // ── Title screen ──────────────────────────────────────────────────────────── private _showTitle(): void { this._loadRoom(7, 3) for (let cy = 0; cy < GRID_H; cy++) { for (let cx = 0; cx < GRID_W; cx++) { if (this._tiles[cx][cy].image_id === IMG_BG) this._tiles[cx][cy].image_id = IMG_BLACK } } this._titleText.text = "CELESTE — quantum remix\n\n" + "arrows / WASD: move K: jump J: dash\n\n" + "Original by Maddy Thorson & Noel Berry\n\n" + "press K, J, Space or Enter to begin" this._titleText.set_font_color(color(0.7, 0.72, 0.82)) } private _hideTitle(): void { this._titleText.set_font_color(color(0, 0, 0, 0)) for (const s of this._snowSprs) s.image_id = IMG_CLEAR this._title = false this._loadRoom(0, 0) } // ── Snow particles (title screen ambience) ────────────────────────────────── private _initSnow(): void { for (let i = 0; i < SNOW_N; i++) { const spd = 0.25 + Math.random() * 5.0 const off = Math.random() const s = floori((Math.random() * 5.0) / 4.0) const col = Math.random() > 0.5 ? IMG_SNOW_W : IMG_SNOW_G const sz = s === 0 ? 0.25 : 0.5 this._snowX.push(Math.random() * 32.0) this._snowY.push(Math.random() * 18.0) this._snowSpd.push(spd) this._snowOff.push(off) this._snowSprs.push(new Sprite(this._engine, col, this._snowX[i], this._snowY[i], 101, sz)) } } private _updateSnow(): void { for (let i = 0; i < SNOW_N; i++) { this._snowX[i] += this._snowSpd[i] * 0.25 this._snowY[i] += -Math.sin(this._snowOff[i] * TAU) * 0.25 this._snowOff[i] += Math.min(0.05, this._snowSpd[i] / 32.0) if (this._snowX[i] > 32.0 + 0.1) { this._snowX[i] = -0.1 this._snowY[i] = Math.random() * 18.0 } this._snowSprs[i].x = this._snowX[i] this._snowSprs[i].y = this._snowY[i] } } // ── Map helpers ───────────────────────────────────────────────────────────── private _tileAt(tx: number, ty: number): number { const mapX = this._roomX * 16 + tx const mapY = this._roomY * 16 + ty if (mapX < 0 || mapX >= 128 || mapY < 0 || mapY >= 64) return 0 return CELESTE_MAP_FLAT[mapY * 128 + mapX] } private _fget(tile: number, flag: number): boolean { if (tile < 0 || tile >= 128) return false return (CELESTE_GFF[tile] & flag) !== 0 } private _solidAt(x: number, y: number, w: number, h: number): boolean { const x0 = floori(x / 8) const y0 = floori(y / 8) const x1 = floori((x + w - 1) / 8) const y1 = floori((y + h - 1) / 8) for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (this._fget(this._tileAt(tx, ty), 1)) return true } } for (const obj of this._objects) { const ot = (obj["_type"] as string) ?? "" const eox = (obj["x"] as number) ?? 0 const eoy = (obj["y"] as number) ?? 0 if (ot === "fall_floor" && obj["_solid"] && !obj["_broken"]) { if (x + w > eox && x < eox + 8 && y + h > eoy && y < eoy + 8) return true } if (ot === "platform") { if (x + w > eox && x < eox + 16 && y + h > eoy && y < eoy + 4) return true } } return false } private _iceAt(x: number, y: number, w: number, h: number): boolean { const x0 = floori(x / 8) const y0 = floori(y / 8) const x1 = floori((x + w - 1) / 8) const y1 = floori((y + h - 1) / 8) for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { if (this._fget(this._tileAt(tx, ty), 16)) return true } } return false } private _spikesAt(x: number, y: number, w: number, h: number, xspd: number, yspd: number): boolean { const x0 = floori(x / 8) const y0 = floori(y / 8) const x1 = floori((x + w - 1) / 8) const y1 = floori((y + h - 1) / 8) for (let ty = y0; ty <= y1; ty++) { for (let tx = x0; tx <= x1; tx++) { const tile = this._tileAt(tx, ty) if (tile === 17) { if (yspd >= 0 && fmod(y + h - 1, 8) >= 6) return true } else if (tile === 27) { if (yspd <= 0 && fmod(y, 8) <= 2) return true } else if (tile === 43) { if (xspd <= 0 && fmod(x, 8) <= 2) return true } else if (tile === 59) { if (xspd >= 0 && fmod(x + w - 1, 8) >= 6) return true } } } return false } // ── Player collision helpers ──────────────────────────────────────────────── private _playerIsSolid(p: GObj, ox: number, oy: number): boolean { const hb = p["hitbox"] as Hitbox const hx = (p["x"] as number) + hb.x + ox const hy = (p["y"] as number) + hb.y + oy if (hx < 0 || hx + hb.w > 128) return true return this._solidAt(hx, hy, hb.w, hb.h) } private _playerIsIce(p: GObj, ox: number, oy: number): boolean { const hb = p["hitbox"] as Hitbox return this._iceAt((p["x"] as number) + hb.x + ox, (p["y"] as number) + hb.y + oy, hb.w, hb.h) } // ── Spawning ────────────────────────────────────────────────────────────── private _spawnPlayer(x: number, y: number): void { this._objects.push({ _type: "player", x, y, spd: { x: 0, y: 0 } as Vec, rem: { x: 0, y: 0 } as Vec, hitbox: { x: 1, y: 3, w: 6, h: 5 } as Hitbox, flip_x: false, grace: 0, jbuffer: 0, djump: MAX_DJUMP, dash_time: 0, dash_effect_time: 0, dash_target: { x: 0, y: 0 } as Vec, dash_accel: { x: 0, y: 0 } as Vec, p_jump: false, p_dash: false, spr_off: 0, spr: 1, hair: [ [x + 4, y + 3], [x + 4, y + 3], [x + 4, y + 3], [x + 4, y + 3], [x + 4, y + 3], ], }) } private _spawnSpring(x: number, y: number): void { this._objects.push({ _type: "spring", x, y, timer: 0 }) } private _spawnFallFloor(x: number, y: number): void { this._objects.push({ _type: "fall_floor", x, y, _solid: true, _broken: false, _state: 0, _timer: 0 }) } private _spawnFruit(x: number, y: number): void { this._objects.push({ _type: "fruit", x, y, _collected: false, _off: Math.random() * TAU }) } private _spawnPlatform(x: number, y: number, dir: number): void { this._objects.push({ _type: "platform", x, y, _dir: dir, _off: 0 }) } // ── Room management ───────────────────────────────────────────────────────── private _loadRoom(rx: number, ry: number): void { this._roomX = rx this._roomY = ry this._hasDashed = false this._objects = [] for (let tx = 0; tx < 16; tx++) { for (let ty = 0; ty < 16; ty++) { const tile = this._tileAt(tx, ty) switch (tile) { case 1: this._spawnPlayer(tx * 8, ty * 8) break case 18: this._spawnSpring(tx * 8, ty * 8) break case 23: this._spawnFallFloor(tx * 8, ty * 8) break case 26: this._spawnFruit(tx * 8, ty * 8) break case 11: this._spawnPlatform(tx * 8, ty * 8, -1) break case 12: this._spawnPlatform(tx * 8, ty * 8, 1) break } } } this._render() } private _nextRoom(): void { if (this._roomX === 7) this._loadRoom(0, this._roomY + 1) else this._loadRoom(this._roomX + 1, this._roomY) } // ── Death ─────────────────────────────────────────────────────────────────── private _killPlayer(): void { this._deaths += 1 this._shake = 10 this._willRestart = true this._delayRestart = 15 this._playerSpr.image_id = IMG_CLEAR } // ── Player movement ───────────────────────────────────────────────────────── private _playerMoveX(p: GObj, amount: number): void { const step = sgn(amount) for (let i = 0; i < Math.abs(amount) + 1; i++) { if (!this._playerIsSolid(p, step, 0)) { p["x"] = (p["x"] as number) + step } else { ;(p["spd"] as Vec).x = 0 ;(p["rem"] as Vec).x = 0 break } } } private _playerMoveY(p: GObj, amount: number): void { const step = sgn(amount) for (let i = 0; i < Math.abs(amount) + 1; i++) { if (!this._playerIsSolid(p, 0, step)) { p["y"] = (p["y"] as number) + step } else { ;(p["spd"] as Vec).y = 0 ;(p["rem"] as Vec).y = 0 break } } } private _playerMove(p: GObj, ox: number, oy: number): void { const rem = p["rem"] as Vec rem.x += ox const ax = floori(rem.x + 0.5) rem.x -= ax this._playerMoveX(p, ax) rem.y += oy const ay = floori(rem.y + 0.5) rem.y -= ay this._playerMoveY(p, ay) } // ── Player update ───────────────────────────────────────────────────────── private _updatePlayer(p: GObj, keys: number[]): void { const has = (k: number) => keys.includes(k) const spd = p["spd"] as Vec const hb = p["hitbox"] as Hitbox let input = 0 if (has(K_RIGHT)) input = 1 else if (has(K_LEFT)) input = -1 if (this._spikesAt((p["x"] as number) + hb.x, (p["y"] as number) + hb.y, hb.w, hb.h, spd.x, spd.y)) { this._killPlayer() return } if ((p["y"] as number) + hb.y + hb.h >= 128) { this._killPlayer() return } const onGround = this._playerIsSolid(p, 0, 1) const onIce = this._playerIsIce(p, 0, 1) const jumpBtn = has(K_JUMP) || has(K_SPACE) const jump = jumpBtn && !p["p_jump"] p["p_jump"] = jumpBtn if (jump) p["jbuffer"] = 4 else if ((p["jbuffer"] as number) > 0) p["jbuffer"] = (p["jbuffer"] as number) - 1 const dashBtn = has(K_DASH) const dash = dashBtn && !p["p_dash"] p["p_dash"] = dashBtn if (onGround) { p["grace"] = 6 if ((p["djump"] as number) < MAX_DJUMP) p["djump"] = MAX_DJUMP } else if ((p["grace"] as number) > 0) { p["grace"] = (p["grace"] as number) - 1 } p["dash_effect_time"] = (p["dash_effect_time"] as number) - 1 if ((p["dash_time"] as number) > 0) { p["dash_time"] = (p["dash_time"] as number) - 1 const dt = p["dash_target"] as Vec const da = p["dash_accel"] as Vec spd.x = appr(spd.x, dt.x, da.x) spd.y = appr(spd.y, dt.y, da.y) } else { const maxrun = 1.0 let accel = 0.6 const deccel = 0.15 if (!onGround) accel = 0.4 else if (onIce) accel = 0.05 if (Math.abs(spd.x) > maxrun) spd.x = appr(spd.x, sgn(spd.x) * maxrun, deccel) else spd.x = appr(spd.x, input * maxrun, accel) if (spd.x !== 0) p["flip_x"] = spd.x < 0 let maxfall = 2.0 let gravity = 0.21 if (Math.abs(spd.y) <= 0.15) gravity *= 0.5 if (input !== 0 && this._playerIsSolid(p, input, 0) && !this._playerIsIce(p, input, 0)) maxfall = 0.4 if (!onGround) spd.y = appr(spd.y, maxfall, gravity) if ((p["jbuffer"] as number) > 0) { if ((p["grace"] as number) > 0) { p["jbuffer"] = 0 p["grace"] = 0 spd.y = -2.0 } else { let wallDir = 0 if (this._playerIsSolid(p, -3, 0)) wallDir = -1 else if (this._playerIsSolid(p, 3, 0)) wallDir = 1 if (wallDir !== 0) { p["jbuffer"] = 0 spd.y = -2.0 spd.x = -wallDir * (maxrun + 1.0) } } } const dFull = 5.0 const dHalf = dFull * 0.70710678118 if ((p["djump"] as number) > 0 && dash) { p["djump"] = (p["djump"] as number) - 1 p["dash_time"] = 4 p["dash_effect_time"] = 10 this._hasDashed = true let vInput = 0 if (has(K_UP)) vInput = -1 else if (has(K_DOWN)) vInput = 1 if (input !== 0) { if (vInput !== 0) { spd.x = input * dHalf spd.y = vInput * dHalf } else { spd.x = input * dFull spd.y = 0 } } else if (vInput !== 0) { spd.x = 0 spd.y = vInput * dFull } else { spd.x = p["flip_x"] ? -1 : 1 spd.y = 0 } this._freeze = 2 this._shake = 6 const dt = p["dash_target"] as Vec const da = p["dash_accel"] as Vec dt.x = 2.0 * sgn(spd.x) dt.y = 2.0 * sgn(spd.y) da.x = 1.5 da.y = 1.5 if (spd.y < 0) dt.y *= 0.75 if (spd.y !== 0) da.x *= 0.70710678118 if (spd.x !== 0) da.y *= 0.70710678118 } } // animation p["spr_off"] = (p["spr_off"] as number) + 0.25 if (!onGround) { p["spr"] = this._playerIsSolid(p, input, 0) ? 5 : 3 } else if (has(K_DOWN)) { p["spr"] = 6 } else if (has(K_UP)) { p["spr"] = 7 } else if (spd.x === 0) { p["spr"] = 1 } else { p["spr"] = 1 + (Math.floor(p["spr_off"] as number) % 4) } this._playerMove(p, spd.x, spd.y) this._updateHair(p) if ((p["y"] as number) < -4 && this._roomY * 16 + this._roomX < 30) this._nextRoom() } private _updateHair(p: GObj): void { const facing = p["flip_x"] ? -1 : 1 const hx = (p["x"] as number) + 4 - facing * 2 const hy = (p["y"] as number) + 3 const hair = p["hair"] as number[][] for (let i = 0; i < 5; i++) { const tx = i === 0 ? hx : hair[i - 1][0] const ty = i === 0 ? hy : hair[i - 1][1] - 2 hair[i][0] += (tx - hair[i][0]) / 1.5 hair[i][1] += (ty - hair[i][1]) / 1.5 } } // ── Entity updates ────────────────────────────────────────────────────────── private _players(): GObj[] { return this._objects.filter((o) => o["_type"] === "player") } private _updateSpring(obj: GObj): void { if ((obj["timer"] as number) > 0) { obj["timer"] = (obj["timer"] as number) - 1 return } for (const p of this._players()) { const hb = p["hitbox"] as Hitbox const px = (p["x"] as number) + hb.x const py = (p["y"] as number) + hb.y if ( px + hb.w > (obj["x"] as number) && px < (obj["x"] as number) + 8 && py + hb.h > (obj["y"] as number) && py < (obj["y"] as number) + 8 ) { const spd = p["spd"] as Vec if (spd.y >= 0) { spd.y = -3.0 p["djump"] = MAX_DJUMP obj["timer"] = 10 } } } } private _updateFallFloor(obj: GObj): void { if (obj["_state"] === 0) { for (const p of this._players()) { const hb = p["hitbox"] as Hitbox const px = (p["x"] as number) + hb.x const py = (p["y"] as number) + hb.y if ( px + hb.w > (obj["x"] as number) && px < (obj["x"] as number) + 8 && py + hb.h >= (obj["y"] as number) && py < (obj["y"] as number) + 4 ) { obj["_state"] = 1 obj["_timer"] = 15 } } } else if (obj["_state"] === 1) { obj["_timer"] = (obj["_timer"] as number) - 1 if ((obj["_timer"] as number) <= 0) { obj["_state"] = 2 obj["_solid"] = false obj["_broken"] = true obj["_timer"] = 60 } } else if (obj["_state"] === 2) { obj["_timer"] = (obj["_timer"] as number) - 1 if ((obj["_timer"] as number) <= 0) { obj["_state"] = 0 obj["_solid"] = true obj["_broken"] = false } } } private _updateFruit(obj: GObj): void { if (obj["_collected"]) return obj["_off"] = (obj["_off"] as number) + 0.05 for (const p of this._players()) { const hb = p["hitbox"] as Hitbox const px = (p["x"] as number) + hb.x const py = (p["y"] as number) + hb.y if ( px + hb.w > (obj["x"] as number) && px < (obj["x"] as number) + 8 && py + hb.h > (obj["y"] as number) && py < (obj["y"] as number) + 8 ) { obj["_collected"] = true p["djump"] = MAX_DJUMP } } } private _updatePlatform(obj: GObj): void { obj["_off"] = (obj["_off"] as number) + 0.65 obj["x"] = (obj["x"] as number) + (obj["_dir"] as number) * 0.65 if ((obj["x"] as number) < -16) obj["x"] = 128 else if ((obj["x"] as number) > 128) obj["x"] = -16 for (const p of this._players()) { const hb = p["hitbox"] as Hitbox const px = (p["x"] as number) + hb.x const py = (p["y"] as number) + hb.y if ( px + hb.w > (obj["x"] as number) && px < (obj["x"] as number) + 16 && py + hb.h >= (obj["y"] as number) && py < (obj["y"] as number) + 4 ) { p["x"] = Math.max(-1, Math.min(121, (p["x"] as number) + (obj["_dir"] as number) * 0.65)) } } } // ── Sprite placement + render ─────────────────────────────────────────────── private _placeSpr(spr: Sprite, px8x: number, px8y: number, img: number, flipH = false): void { spr.image_id = img spr.x = OX + px8x / 8 spr.y = OY + (L - 1) - px8y / 8 spr.flip_h = flipH } private _renderTiles(): void { for (let cx = 0; cx < GRID_W; cx++) { for (let cy = 0; cy < GRID_H; cy++) this._tiles[cx][cy].image_id = IMG_BG } this._tileCanvas.clear() for (let tx = 0; tx < 16; tx++) { for (let ty = 0; ty < 16; ty++) { const tile = this._tileAt(tx, ty) const cx = OX + tx const cy = OY + L - 1 - ty let imgId: number if (tile === 0) { imgId = IMG_BG } else if (tile >= 128 || [1, 11, 12, 18, 23, 26].includes(tile)) { imgId = IMG_CLEAR } else { imgId = tile this._tileCanvas.set(`${cx},${cy}`, tile) } this._tiles[cx][cy].image_id = imgId } } } private _renderEntities(): void { for (const spr of this._entitySprs) spr.image_id = IMG_CLEAR for (const spr of this._hairSprs) spr.image_id = IMG_CLEAR let sprIdx = 0 for (const obj of this._objects) { if (sprIdx >= this._entitySprs.length) break switch (obj["_type"]) { case "spring": this._placeSpr(this._entitySprs[sprIdx], obj["x"] as number, obj["y"] as number, 18) sprIdx++ break case "fall_floor": if (!obj["_broken"]) { this._placeSpr(this._entitySprs[sprIdx], obj["x"] as number, obj["y"] as number, 23) sprIdx++ } break case "fruit": if (!obj["_collected"]) { const bobY = (obj["y"] as number) + Math.sin(obj["_off"] as number) * 2.0 this._placeSpr(this._entitySprs[sprIdx], obj["x"] as number, bobY, 26) sprIdx++ } break case "platform": this._placeSpr(this._entitySprs[sprIdx], obj["x"] as number, obj["y"] as number, 11) sprIdx++ break } } let playerDrawn = false for (const obj of this._objects) { if (obj["_type"] !== "player") continue const hairImg = (obj["djump"] as number) > 0 ? IMG_HAIR_DASH : IMG_HAIR_NODASH const hair = obj["hair"] as number[][] for (let i = 0; i < 5; i++) { const hi = 4 - i // draw tip (big) first, head (small) last const sz = (hi + 1) / 8 const seg = hair[hi] this._hairSprs[i].size = sz this._hairSprs[i].image_id = hairImg this._hairSprs[i].x = OX + seg[0] / 8 - sz / 2 this._hairSprs[i].y = OY + (L - 1) - seg[1] / 8 - sz / 2 } this._placeSpr(this._playerSpr, obj["x"] as number, obj["y"] as number, obj["spr"] as number, obj["flip_x"] as boolean) playerDrawn = true break } if (!playerDrawn) this._playerSpr.image_id = IMG_CLEAR this._statusText.text = "deaths: " + this._deaths } private _render(): void { this._renderTiles() this._renderEntities() } // Quantum flicker: each canvas tile occasionally jumps to one of its variant // banks (pair / single), evoking a measured qubit flickering between states. private _flickerTiles(): void { for (const [key, base] of this._tileCanvas) { if (Math.random() < 1 / 30) { const [cx, cy] = key.split(",").map(Number) const bank = [0, PAIR_OFFSET, SINGLE_OFFSET][Math.floor(Math.random() * 3)] this._tiles[cx][cy].image_id = base + bank } } } // ── Per-frame ────────────────────────────────────────────────────────────── process(_delta: number, engine: Coccoon): void { const inp = engine.update() const keys = inp.key_presses const justPressed = keys.filter((k) => !this._prevKeys.includes(k)) this._prevKeys = keys.slice() if (this._title) { this._updateSnow() this._flickerTiles() if (justPressed.includes(K_DASH) || justPressed.includes(K_JUMP) || justPressed.includes(K_SPACE)) this._hideTitle() return } if (this._willRestart) { this._delayRestart -= 1 if (this._delayRestart <= 0) { this._willRestart = false this._loadRoom(this._roomX, this._roomY) } return } if (this._freeze > 0) { this._freeze -= 1 return } if (this._shake > 0) this._shake -= 1 let dead = false for (const obj of this._objects) { if (obj["_type"] === "player") { this._updatePlayer(obj, keys) if (this._willRestart) { dead = true break } } } if (dead) { this._renderEntities() this._flickerTiles() return } for (const obj of this._objects) { switch (obj["_type"]) { case "spring": this._updateSpring(obj) break case "fall_floor": this._updateFallFloor(obj) break case "fruit": this._updateFruit(obj) break case "platform": this._updatePlatform(obj) break } } this._renderEntities() this._flickerTiles() } } ``` ## app/api/moth-blur/route.ts — Atlas job proxy How a quantum job reaches the Atlas platform from the app: a server proxy that submits a job and polls it. The browser never sees the API key. ```ts // Server proxy for the Moth platform's blur-core-v1 engine. // // This is the tutorial's core: a coccoon game hands us a grid, and we run the // quantum blur on Atlas (the Moth platform) rather than locally. The engine is // asynchronous and a job can take a couple of minutes, so we DO NOT hold one // request open for the whole job. Instead the browser drives two fast actions: // // 1. { action: "submit", values, strength, key } // POST /api/v1/engines/blur-core-v1/process // -> 202 { job_id, status: "queued" } // We return { jobId }. // // 2. { action: "poll", jobId, key } // GET /api/v1/jobs/{jobId}/status -> { status, result?: { output } } // and return { status: "completed", output } once done. Otherwise { status }. // // IMPORTANT — poll /status, NOT /jobs/{id}. The platform exposes two reads of a // job and they are NOT equivalent: GET /api/v1/jobs/{id} is an // eventually-consistent record that can keep reporting "queued" for MINUTES // after the job has actually finished, whereas GET /api/v1/jobs/{id}/status is // the authoritative live status and embeds result.output inline on completion. // Polling the former is what made jobs look like they "hang until you open the // dashboard" (the dashboard reads live status). Verified against the live API // on 2026-09-26: the two endpoints returned "queued" and "completed" for the // same job at the same instant. /status also saves a round-trip since it // carries the result; we fall back to GET /api/v1/jobs/{id}/result only if the // inline output is ever absent. // // The key comes from the request (entered in the coccoon menu / game UI, like // the original's coccoon.get_api_key()) or falls back to MOTH_API_KEY. It is // used only as a Bearer token to the platform and is never persisted. // // Correctness notes for a polling proxy: // * Job status is live state, so EVERY upstream call is `cache: "no-store"` // and the route is pinned dynamic. A cached status snapshot would make the // job look stuck at "queued" forever while it actually finished upstream. // * Every upstream call is bounded by an AbortController timeout so a hung // socket returns promptly instead of eating the serverless budget. // * Errors are classified: 4xx (auth/validation) are terminal and reported so // the client stops immediately; network/timeout/5xx are marked retryable so // the client backs off and tries again. const API_BASE = "https://api.mothquantum.com" // Never cache anything in a polling proxy: each request must reflect live job // state on the platform. export const dynamic = "force-dynamic" export const fetchCache = "force-no-store" // Each call here is a single fast round-trip to the platform, so the default // serverless budget is plenty; the long wait lives in the browser's poll loop. export const maxDuration = 30 // Upstream calls must never hang a poll for the whole serverless budget. const UPSTREAM_TIMEOUT_MS = 12_000 function json(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store, no-cache, must-revalidate", }, }) } // A single uncached, time-bounded round-trip to the platform. Returns the // parsed JSON body (or null) alongside the response so callers can classify the // outcome. A timeout or network failure surfaces as { res: null }. async function upstream( url: string, init: RequestInit, ): Promise<{ res: Response | null; body: Record | null }> { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS) try { const res = await fetch(url, { ...init, cache: "no-store", signal: controller.signal }) const body = (await res.json().catch(() => null)) as Record | null return { res, body } } catch { return { res: null, body: null } } finally { clearTimeout(timer) } } type Payload = { action?: "submit" | "poll" values?: number[][] strength?: number jobId?: string key?: string } export async function POST(req: Request) { let payload: Payload try { payload = (await req.json()) as Payload } catch { return json({ error: "invalid_json" }, 400) } const key = payload.key && payload.key.length > 0 ? payload.key : process.env.MOTH_API_KEY if (!key) return json({ error: "missing_key" }, 401) const auth = { Authorization: `Bearer ${key}` } if (payload.action === "submit") { const values = payload.values if (!Array.isArray(values) || values.length === 0) { return json({ error: "missing_values" }, 400) } const strength = typeof payload.strength === "number" ? payload.strength : 0.25 const { res, body } = await upstream(`${API_BASE}/api/v1/engines/blur-core-v1/process`, { method: "POST", headers: { ...auth, "Content-Type": "application/json" }, body: JSON.stringify({ params: { values, strength } }), }) // No response at all = timeout/network: retryable. if (!res) return json({ error: "submit_unreachable", retryable: true }, 504) // Auth/validation rejections are terminal — don't let the client spin. if (res.status === 401 || res.status === 403) return json({ error: "unauthorized", retryable: false }, 401) const jobId = body?.job_id if (!res.ok || typeof jobId !== "string") { return json({ error: "submit_failed", status: res.status, retryable: res.status >= 500 }, 502) } return json({ jobId, status: typeof body?.status === "string" ? body.status : "queued" }) } if (payload.action === "poll") { const jobId = payload.jobId if (!jobId) return json({ error: "missing_job" }, 400) // Poll the authoritative live status, not the lagging /jobs/{id} record. const { res, body } = await upstream(`${API_BASE}/api/v1/jobs/${jobId}/status`, { headers: auth }) if (!res) return json({ error: "poll_unreachable", retryable: true }, 504) if (res.status === 401 || res.status === 403) return json({ error: "unauthorized", retryable: false }, 401) const status = typeof body?.status === "string" ? body.status : null if (!res.ok || !status) { return json({ error: "poll_failed", status: res.status, retryable: true }, 502) } if (status === "completed") { // /status embeds the result inline; use it and avoid the extra round-trip. const inline = (body?.result as { output?: number[][] } | undefined)?.output if (Array.isArray(inline)) return json({ status, output: inline }) // Fallback: fetch the dedicated result endpoint only if output is absent. const { res: rres, body: rbody } = await upstream(`${API_BASE}/api/v1/jobs/${jobId}/result`, { headers: auth }) if (!rres) return json({ error: "result_unreachable", retryable: true }, 504) const result = rbody?.result as { output?: number[][] } | undefined const output = result?.output ?? null if (!rres.ok || !Array.isArray(output)) { return json({ error: "result_failed", status: rres.status, retryable: rres.status >= 500 }, 502) } return json({ status, output }) } if (status === "failed" || status === "error" || status === "cancelled") { return json({ status, error: body?.error ?? "job_failed" }) } // queued / running / anything else still in flight return json({ status }) } return json({ error: "unknown_action" }, 400) } ```