AI co-written: A first-person account by Kyösti — written together with Claude from the game’s real source and Claude Code session logs. This is the engineer’s cut.

Inside Dawn of Kvartaali: A Zero-Dependency Game Engine

This is the engineer’s cut of Dawn of Kvartaali — a LucasArts-style pixel adventure that runs on an engine with nothing underneath it. No framework, no game toolkit, no build step: just plain browser scripts drawing a 320×180 world by hand, a from-scratch Web Audio synth, three runtime languages (one of them pure caveman), and a test that plays the whole game before every deploy. The story series tells the human story; this is the wiring diagram.

Dawn of Kvartaali — a four-part series
  1. Part 1 · The build — a game engine, and a robot that plays the game
  2. Part 2 · The design — pixels, jokes, and two dozen songs it can’t hear
  3. Part 3 · The workflow — zero lines written by hand
  4. Part 4 · The architecture — inside the engine (you are here)

The story series is about what it felt like to build a game by directing an AI. This is the counterpart: what the machine actually is, section by section, read straight out of the source. I had the idea and every opinion; Claude wrote every line — engine, art, music and content — so where the details get specific below, that is Claude describing its own work. None of it is aspirational. It is all in the tree.

Here is the whole architecture in one sentence, and then we go deep: the game is zero-dependency ES2020, no build step, a handful of plain <script> tags loaded in order into a single global namespace called DOK — and the page that hosts it knows nothing about the game at all.

A game with no engine under it

Most games stand on a big off-the-shelf toolkit. This one stands on nothing. js/engine/ is about fifteen hand-written files that together make a complete point-and-click runtime, and index.html is deliberately dumb: a canvas and a list of scripts to load, nothing more. Everything the engine exposes hangs off one global, DOK — a namespace that also carries the content registries (rooms, actors, dialogue, themes) and the i18n core, all stood up in boot.js before anything else runs.

The wall between engine and content is strict, and it is worth naming because it is what let the game grow without collapsing. The engine files — gfx, sprite, actor, scene, ui, dialogue, cutscene, game, overlays, achievements, update, main — never mention a specific room or a specific joke. The content is data registered against those systems: 39 rooms, 21 quests, 26 actors, 24 music themes, 21 dialogue files, 11 scripts and minigames, 14 data modules, and just under 4,000 English source strings. Even combat is content — the one fight in the game lives in game/script/combat.js, hosted by the engine rather than baked into it.

Drawing 320×180

Everything you see is painted onto a logical canvas exactly 320×180 pixels, then upscaled to fill your screen with image smoothing switched off — which is where the crisp, chunky look comes from. There are no image files anywhere; gfx.js holds the palette, the drawing primitives, a pixel-font renderer and a word-wrapper.

Colour is the first thing that feels different. Nothing addresses a colour by hex. The palette is DB32 — thirty-two named entries on DOK.PAL — and you draw with the names, not numbers: a wall panel is PAL.plum, a floor is PAL.coal, the last light of the day is PAL.sun. Naming the palette this way is a small decision that pays off constantly — a scene reads like a description, and re-tinting a room for a different era becomes a matter of swapping names rather than hunting hex codes.

The frame is composited in layers — a background, a light layer, and a foreground — blended every frame. On top of that sits a small painterly toolkit that does Bayer ordered-dithering for gradients, glows, shadow bands and vignettes; that ordered dither is what lets a flat 32-colour palette fake soft light and depth. For 4K displays there is an optional “ULTRA” path that swaps the dithering for real canvas gradients when it has the pixels to justify them.

bg layerroom, static cache
light layerglow, shadow band
fg layeractors, floor items
dither / ULTRABayer ordered-dither — or real gradients at 4K
upscale320×180 → screen, nearest-neighbour
Three layers composite every frame; a painterly pass adds ordered-dithered light; then the 320×180 buffer is blown up with no smoothing. ULTRA swaps the dither for true gradients when the display can spend the pixels.

Two more details live in this layer. The playfield only owns the top of the screen — rows 0 to 125 — while an opaque, Sierra-style status band owns rows 126 to 179; the world never draws into the UI. And the text is a hand-authored 5×7 pixel font in js/font.js with a 6-pixel advance, drawn glyph by glyph, including the letters a Finnish game cannot live without: Ä, Ö, Å and É. Sprites are their own little compiler: sprite.js takes frames written as pixel strings and bakes them into offscreen canvases once, auto-mirroring the left/right facings so only one side is ever drawn.

An open-plan office rendered in pixel art at 320×180, with depth-scaled characters, a lit window wall, a highlighted sentence line, and the opaque status band across the bottom
One 320×180 frame: a background wall, a dithered light gradient in the windows, depth-scaled actors on the floor, and the opaque status band owning the bottom rows. Every pixel is emitted by code — there are no image assets.

Rooms, verbs, and hit-testing

A room is a scene: a static layer cached to an offscreen bitmap so the unchanging parts are not repainted every frame, plus floor items, hotspots and exits. ui.js draws the interface into the status band — the SCUMM verb bar (Give, Pick up, Use, Open, Look at, Push, Close, Talk to, Drink), the inventory, the sentence line that reads back what you are about to do, the HUD and the cursor.

The office lobby with the SCUMM verb bar, an inventory grid, a 'Walk to' sentence line, and a status HUD showing time and score along the top
The status band, drawn entirely by ui.js: verb bar, inventory grid, sentence line and HUD. A click resolves against the scene in a fixed priority — actors, then items, then hotspots, then exits.

The part I would point a new engineer at is that hit-testing order. When you click, the scene resolves the target in a fixed priority: actors first, then floor items, then hotspots, then exits. That order is not cosmetic — it is a bug class. Get it wrong and a large hotspot swallows the doorway behind it, which is exactly one of the defects the test gate later caught. Conversations are their own subsystem: dialogue.js runs typewriter text, branching choices, and 56×56 talking portraits that animate a mouth and a blink while a character speaks.

Saving, and the death you can’t un-die

Saves are versioned slots in localStorage under dok_* keys, written and restored by the main controller in game.js. Persistent state is not only saves: the 28 achievements and the quest log live in localStorage too, through achievements.js and overlays.js — the latter also drawing the title menu, the building map, the document viewer and the pause screen. All standard, with one deliberately cruel exception.

The game keeps a death counter — your tally of demises, shown on your in-game CV — and that number is stored outside the save file, in its own key. So when a Sierra-style death drops you back and you hit RETRY, or you reload the page entirely, your progress rolls back but your death count does not:

// Progress is a versioned slot. Your death count is NOT part of it.
localStorage.setItem('dok_slot_' + n, serialize(state)); // RETRY / reload restores this
localStorage.setItem('dok_deaths', String(deaths + 1));  // the career ledger — outside the slot

It is a one-line design decision that changes how the whole game feels: because the ledger is a sibling of the save rather than a field inside it, no reload can quietly erase a mistake. Very true to working life.

A turn-based confrontation screen with Attack, Use Item and Flee options over a pixel-art scene
The game’s single turn-based fight. Win or lose, the whole encounter is content in game/script/combat.js — and any death it produces is tallied in the ledger the save file can’t reach.

Composing on a synth it can’t hear

The music is generated, not played back — there are no audio files either. The soundtrack runs on a procedural Web Audio synth written from scratch, and a theme is a declaration:

DOK.registerTheme('sauna_dnb', {
  bpm: 174, root: 'D', mode: 'minor', stepsPerBar: 16, bars: 8,
  tracks: [
    { voice: 'drum' },                                    // kick / snare / hat / tom / clap
    { voice: 'synth', wobbleHz: 3.2, wobbleDepth: 0.7,    // reese bass
      wobbleWave: 'saw', filter: { cutoff: 400, q: 12, env: 0.9 } },
    { voice: 'fm', /* default 2-op FM lead */ },
  ],
});

Every voice in that tracks array is hand-built. There is a drum machine (kick, snare, hat, tom, clap, rimshot); a subtractive synth with a resonant low-pass filter envelope and an optional LFO wobble — wobbleHz, wobbleDepth, wobbleWave — which is how the acid and reese basses get their movement; a pluck; a wave voice; and a default two-operator FM voice for leads. Around two dozen original themes are declared this way, one per mood and place in the building.

Two behaviours lift it above wallpaper. The music is sobriety-reactive: as the character drinks, the engine shifts tempo through buckets, detunes, and even places deliberate wrong notes, so the score itself grows woozier. And it ducks under speech, dropping its own level whenever a line is being spoken. There are even vocals — sung lines produced through the browser’s SpeechSynthesis engine, an AI pressing the operating system’s own text-to-speech into service as a singer.

Worth stating once in an engineering piece: Claude composed all of this deaf. It reasoned about tempo, mode and voicing, wrote the declaration, and trusted a pair of two-word verdicts — “lame” or “excellent” — as its only ears.

Three languages, one of them caveman

The game ships bilingual — English and Finnish, held to 100% coverage — plus a third runtime language that is a joke taken seriously. Internationalisation runs two strategies at once. Content strings are source-as-key: you call DOK.T("English text") and the English literal is the key. UI chrome uses opaque keys instead: DOK.U('btn.log').

Source-as-key is ergonomic but fragile — change one English word and the key changes, orphaning its translation. The fix is tools/i18n.js, a gettext-style msgmerge that uses fuzzy Levenshtein matching, at a 0.6 threshold, to re-key the Finnish (and caveman) entries when the English drifts, so a translation is re-attached rather than lost. A fiCoverage() check enforces 100% Finnish and is gate-checked, so the game can never ship with an untranslated line.

The third language is CAVEMAN/UGH, and it is a transform, not a dictionary: a lexicon, plus dropped function words, plus a deterministic, hash-seeded seasoning, applied at runtime. Because the seasoning is seeded rather than random, the same line always cavemans the same way — which, not by accident, is what makes it testable.

The manifest, the loader, and one-URL cache-busting

Loading order matters when you have no bundler to sort it for you. assets.js is the single source of truth: one ordered manifest, dependency-sorted, boot.js first and main.js last. From it, tools/bootstrap.js generates bootstrap.js — the actual list of <script> tags — and the test gate fails if that generated file is stale, so the manifest and the loader can never quietly drift apart.

The nicer trick is cache-busting. On deploy, tools/release.js stamps every script URL with a hash of that file’s own contents — not the commit hash. The difference is the entire point. With a commit hash, any change re-stamps every URL and a returning player re-downloads the whole ~2 MB game. Hashing each file by its own bytes means a one-line fix changes exactly one URL: the browser fetches that single script and serves everything else from cache.

The updater that waits until you’re standing still

js/engine/update.js is a self-updater with manners. It polls version.json roughly every six minutes — with a 90-second floor, and an extra check whenever you switch back to the tab. When a new version exists it does not barge in. It offers a reload only when you are genuinely idle: standing in a room, no dialogue open, no walk in progress.

// update.js — only interrupt a player who is doing nothing.
function safeToReload(g) {
  return g.state === 'play'      // not a menu, cutscene, fight or death screen
      && !g.dialogue.active      // never mid-line
      && g.walkTarget == null;   // no pending move
}
if (updateReady && safeToReload(game)) {
  stashResumeMarker(game.room, game.actor.pos); // remember exactly where we stood
  location.reload();                            // ...and come back to that spot
}

Never mid-line, mid-fight, or mid-death. When you accept, it stashes a resume marker and calls location.reload(), and you return standing exactly where you were. And there is deliberately no service worker — the update path is this one honest poll, not a caching layer that could hand you a stale game.

The game that tests itself

The safety net is the part I am proudest of, and it is why a game built this fast is also sturdy. tests/run.sh has to be green before anything deploys, and it is a pipeline:

syntaxnode --check, every .js
content lintno dangling room / actor / dialogue refs
gameplay simdrives the real engine: every ending, quest, death, save/load, minigame
coverage sweepevery room / verb / doc / egg reachable · 100% Finnish
deployonly if green
Green-before-deploy. The simulator actually plays the game rather than mocking it; the sweep proves nothing is unreachable and the Finnish is complete. Because the gate can’t see, it is paired with headless-Chrome screenshots.

Syntax first — node --check on every .js. Then a content lint that rejects dangling references (a room, actor or dialogue pointing at something that does not exist). Then the centrepiece: a gameplay simulator that drives the real engine — not a mock — through every ending, every quest, every death, every save/load and every minigame. Then the loader and cache-bust check. Then an availability-and-coverage sweep that proves every room, verb, dialogue, document, easter egg and hint is reachable, and that the Finnish is at 100%.

There is a house rule attached, and it carries real weight: you never weaken a test to make it pass. If a test asserts the old behaviour after a deliberate change, the test is the bug — you rewrite it, with a comment saying why. Because the simulator cannot see, it is paired with headless-Chrome screenshots (tests/shot.sh) for the defects only an eye catches. Between them they caught real ones: ending soft-locks, an unwinnable minigame, hotspots so large they hid the exit behind them, the death counter that rolled back on RETRY, and a performance bug where a full-screen dither was issuing roughly 51,000 fillRect calls per frame — fixed by caching that dither to an offscreen bitmap once instead of redrawing it forever.

Who built it: agents under a contract

The last thing worth saying is how it was made, because the method shaped the architecture. The whole game was built by directing Claude Code. I wrote zero lines — of code, art, music or content. Work was fanned out to many parallel agents, and the thing that kept dozens of them producing pieces that actually fit was a single CONTRACT.md that fixed every content format: the sprite pixel-string syntax, the room definitions, the dialogue ops, the FM theme shapes. Define the formats once, precisely, and the filling-in parallelises.

The test gate closed the loop. An agent builds a room; the gate plays it; if the room breaks an ending or leaves a hotspot over a door, the gate goes red and the work isn’t done. A machine writing content against a machine that plays it — that feedback is the real engine under the engine.

That is the engineer’s cut. For the human story — the single-paragraph brief, the swarm of agents, and the small miracle of two dozen songs composed by something with no ears — start with the story series. Dawn of Kvartaali currently lives as an invitation-only beta at kvartaali.fi.