A complete Tetris that renders nowhere.
The page is empty. Not "mostly empty" — the <body> never receives a single element, and
there is a test that fails if it ever does. There is no canvas on the page, no div, no text.
The game is still there. It is running in the parts of the browser that were never meant to be a display.
Not a screen recording. That animation is rendered by the engine in this repository, using the
same paintBoard() that draws your tab, and regenerated with npm run demo — same seed, same
file, down to the byte. See how the demo is made.
Zero dependencies. No build step. One file. Open index.html and look at the tab.
| Channel | What it does |
|---|---|
| Favicon | The board itself. A 64×64 canvas is redrawn 15 times a second, turned into a PNG data URL and pushed into <link rel="icon"> — including the hold slot, the next queue and a level gauge in the margins. |
| Tab title | The skyline of the stack as block glyphs, plus score, level, combo and back-to-back state. |
| Address bar | The same skyline mirrored into location.hash via replaceState — no history entries, just a very bad display. |
| Console | A colour board with a stats sidebar (ghost.board()), or the real canvas smuggled in as a CSS background image (ghost.frame()). Plus achievements, replay codes and score sparklines. |
| Web Audio | A chiptune engine. No audio files — every note is synthesised: square-wave lead, triangle bass, noise hi-hat, and sound effects for moves, spins, clears and level-ups. |
| OS media panel | The synth is routed through a MediaStream, so the browser treats the empty page as a media source. The game appears in the Windows media flyout / macOS Now Playing with the live board as cover art, and your keyboard's media keys pause and restart it. |
| localStorage | Records, achievements and score history, synced live between tabs over BroadcastChannel. |
Everything else — gamepad, touch gestures, vibration, desktop notifications on a new record — works on the same empty surface.
git clone https://github.com/Wasserpuncher/ghost-runtime.git
cd ghost-runtime
# no install step, there is nothing to installOpen index.html in a browser. Then:
- Look at the tab icon. That is the game, playing itself.
- Press a key — you take over, and the sound switches on.
- Open the console and type
ghost.help().
To run the checks:
npm test # 69 unit tests, no dependencies
npm run verify:browser # loads the page in headless Chromium and asserts what happened
npm run demo # regenerates the animation at the top of this fileThe page has no UI, so every input is a key, a button or a gesture.
| Input | Action |
|---|---|
← → |
Move (with DAS/ARR auto repeat) |
↓ |
Soft drop |
↑ / X |
Rotate clockwise |
Y / Z |
Rotate counter-clockwise |
Space |
Hard drop |
C |
Hold |
P |
Pause · M mute · A autopilot · R restart · ? help |
| Gamepad | D-pad or stick to move, A rotate, B counter-rotate, X hold, Y hard drop, Start pause |
| Touch | Swipe sideways to move, down to drop, up to hold, tap to rotate |
| Konami code | Turbo |
| Media keys | Play/pause the game, next track restarts it |
Take over at any time; the autopilot steps back and returns after 20 seconds of silence.
ghost.help() // everything below, printed in colour
ghost.board() // draw the field here, with a stats sidebar
ghost.frame() // render the real canvas as a console image
ghost.live(true) // live console rendering at 4 fps
ghost.ai(false) // take control yourself
ghost.turbo() // triple speed
ghost.sound(false) // mute the chiptune engine
ghost.theme('gameboy') // neon · gameboy · mono · ice
ghost.title('minimal') // shorter tab title
ghost.url(false) // stop mirroring the stack into the URL
ghost.stats() // records, achievements, score history
ghost.seed('k3f9x') // restart from a specific seed
ghost.replay() // export this game as a replay code
ghost.play(code) // watch a replay, move for move
ghost.redraw() // repaint every channel now, ignoring the frame limits
ghost.state // current runtime snapshotflowchart TD
CLOCK["Clock<br/>rAF when visible<br/>setInterval when hidden"] -->|"fixed 60 Hz steps"| SIM
subgraph SIM["Simulation (deterministic)"]
RNG["Seeded RNG<br/>xorshift32"] --> ENGINE
ENGINE["Engine<br/>SRS · lock delay · T-spins<br/>combo · back-to-back"]
end
INPUT["Keyboard · Gamepad · Touch"] --> FUNNEL
AI["Autopilot<br/>Dellacherie features<br/>+ 1 piece lookahead"] --> FUNNEL
REPLAY["Replay playback"] --> FUNNEL
FUNNEL["input funnel<br/>every action recorded as (tick, action)"] --> ENGINE
ENGINE --> STATE["board · score · events"]
STATE --> FAV["Favicon<br/>canvas → PNG data URL"]
STATE --> TITLE["Tab title<br/>skyline glyphs"]
STATE --> HASH["Address bar<br/>replaceState"]
STATE --> CONSOLE["Console<br/>%c board · achievements"]
STATE --> AUDIO["Web Audio<br/>chiptune + SFX"]
AUDIO --> MEDIA["OS media panel<br/>MediaStream + cover art"]
STATE --> STORE["localStorage<br/>BroadcastChannel"]
A 64×64 canvas gets a 10×20 board at three pixels per cell, with the hold piece, the next two
pieces and a level gauge tucked into the margins. canvas.toDataURL() produces a PNG, the PNG
goes into link.href, and the browser repaints the tab. At 15 fps that is roughly 900 icon
swaps a minute, and it costs almost nothing because the canvas is tiny.
Hidden tabs throttle both requestAnimationFrame and setInterval, so the clock switches to an
interval on visibilitychange: in the background the game slows down, but it never stops — and
the favicon keeps animating in the tab strip, which is exactly where you can still see it.
The simulation runs on a fixed 60 Hz timestep driven by a seeded xorshift32 PRNG, and
every input — keyboard, gamepad, touch, autopilot, replay playback — goes through a single
funnel that records it as (tick, action).
That makes a whole game reproducible from a seed plus a list of inputs. Delta-encoded as varints and base64url'd, a long game fits in a few hundred bytes:
ghost.replay() // "GT1.1x3k9f.CAECAQMBBgIBAQMBBgQBAQEGAgEDAQY…"
ghost.play(code) // replays it move for move, on any machineThe determinism is not a claim, it is a test: a recorded autopilot game is replayed headlessly and the resulting board is compared cell by cell, together with the score, the piece in flight and the preview queue.
Dellacherie's feature set — landing height, eroded piece cells, row transitions, column transitions, holes and well sums — with the published "El-Tetris" weights, extended by a one-piece lookahead over a six-candidate beam and a hold evaluation.
One detail matters more than it looks. A naive lookahead scores a branch as
score(board₁) + w · score(board₂), which counts the same stack twice and makes the bot decline
free line clears. Here the evaluation is split: move terms (landing height, eroded cells)
accumulate along the branch, the board terms are evaluated once, at the end.
Measured over five seeds, 3000 pieces each, on Node 21 on one desktop machine:
| Deaths | 0 / 5 |
| Lines cleared | ~1199 per 3000 pieces |
| Efficiency | ≈ 11 990 of 12 000 placed cells cleared again |
| Planning cost | 0.84 ms per piece |
The run stops at 3000 pieces because the cap is reached, not because the bot dies — how long it would actually last is untested.
It plays for survival, not for style: it clears singles and doubles endlessly and almost never
builds a Tetris. Take over with A if you want a prettier game.
Two oscillators and a noise buffer, scheduled against AudioContext.currentTime with a 150 ms
lookahead, tempo rising with the level. The melody is Korobeiniki, a 19th century Russian
folk song in the public domain — transcribed as note names in the source, not sampled.
Audio can only start after a user gesture (browser autoplay policy), which is why the first key press does double duty: it hands you the controls and switches the sound on.
Once audio is running, the master gain is routed into a MediaStreamDestination and attached to
an <audio> element. The browser now considers the page to be playing media, which unlocks the
Media Session API: metadata, cover art regenerated from a 256×256 render of the board, and
action handlers so the media keys on your keyboard control a game that has no visible surface.
tools/make-demo.js runs the engine, hands paintBoard() a stand-in 2D context that writes into
an indexed pixel buffer, draws the mock browser chrome around it with a hand-rolled 3×5 pixel
font, and encodes the result with tools/gif.js — a GIF89a writer in 150 lines.
That writer skips LZW compression with a documented trick: it emits a valid LZW stream made only of literal codes, resetting the dictionary often enough that the decoder never widens its codes. Frame differencing does the actual work — only the icon, the title and the address bar are re-encoded per frame, so twelve seconds of animation fit in about 180 KiB.
For an honest picture: the stack you see at the start is built by random placements — six of them with the default seed, stopping once the stack is nine rows deep. Left to its own devices the autopilot keeps the board nearly empty, which is excellent play and very boring footage. Everything after that first frame is the real autopilot digging out: 50 pieces, 21 lines, 16 clears on camera.
npm run demo # regenerates assets/demo.gif, byte for byte
node tools/make-demo.js 12345 200 # different seed, more frames69 tests, no framework, no dependencies — a runner, an assertion helper and a fake browser:
engine / pieces SRS shapes, rotation identity, wall kicks, 7-bag, seeding
engine / board line clears, Tetris scoring, back-to-back, perfect clear, combos
engine / t-spins the 3-corner rule, and what is not a T-spin
engine / timing gravity curve, lock delay, the 15 reset limit, block out
ai / simulation drop placement, multi-row clears, eroded cell counting
ai / features holes, wells, row and column transitions
ai / planning candidate legality, obvious clears, 300 piece survival run
replay / encoding round trip, varint deltas, compactness, rejecting garbage
replay / determinism a recorded game replayed cell for cell
gif / lzw a spec-following decoder reads back what the writer wrote
gif / container header, colour table, loop extension, partial frames
runtime / boot favicon link installed, body untouched, tab actually animates
every drawn rectangle stays inside the 64x64 canvas
runtime / control keyboard, DAS repeat, browser shortcuts left alone, pause
runtime / replays live recording reproduced headlessly
runtime / environment hidden tab clock, persistence, achievements, event wiring
The runtime tests boot the real thing against a fake browser: a canvas that records every
rectangle, a controllable clock, a localStorage map and a history spy. They assert, among
other things, that document.body is never touched — the premise of the project is enforced by
CI.
The GIF tests are worth a note: the decoder in test/gif.test.js implements the real LZW
algorithm, dictionary and all, and checks that it never has to widen its codes. Writing it
immediately caught a bug — the writer's reset interval was only correct for 16-colour palettes.
index.html an empty page, mostly comments explaining why it is empty
style.css a reset, a background colour, a noscript fallback
script.js the entire runtime, one file, no dependencies
assets/demo.gif the README animation, generated by tools/make-demo.js
wrangler.jsonc Cloudflare Workers deploy config (static assets only)
.assetsignore the allowlist of what may leave the repo for the edge
tools/
make-demo.js renders the animation with the real engine
gif.js a GIF89a writer, no dependencies
browser-check.js runs the page in headless Chromium and asserts the result
probe.html the page that check loads
test/
run.js entry point: node test/run.js
harness.js tiny test framework + fake browser
engine.test.js rules and scoring
ai.test.js evaluation and planning
replay.test.js encoding and determinism
gif.test.js the GIF writer, checked with a real LZW decoder
runtime.test.js boots the real runtime against the fake browser
Two different kinds of claim live here, and they are kept apart on purpose.
Checked in a real browser. npm run verify:browser loads the runtime in a headless
Chromium-based browser and asserts what actually happened. Run against Microsoft Edge on Windows
over file://, all of it passes:
ok the runtime boots ok the title carries the skyline glyphs
ok no uncaught errors ok the address bar shows the skyline
ok nothing but the probe markup in the body ok fragment-only replaceState is accepted
ok the favicon is a PNG data URL ok the console API is exposed
ok the tab title was rewritten
favicon 558 characters of data URL
title ▶ ▁▁▁▁▁▁▁▁▁▁ · 0 · Lv1
fragment #▁▁▁▁▁▁▁▁▁▁ (raw: #%E2%96%81%E2%96%81%E2%9...)
A detail worth knowing if you build something similar: the fragment comes back from
location.hash percent-encoded, even though the address bar displays the glyphs.
Not checked anywhere. Audio, the OS media panel and gamepads all need a user gesture or real
hardware, so nothing automated can reach them. Firefox and Safari have not been tested at all —
Safari in particular is widely reported not to repaint data-URL favicons reliably, and
ghost.frame() needs background-image support in console styling, which is Chromium-only.
Every channel is wrapped so a missing or refused API disables that one feature and leaves the rest running. If you test it somewhere I could not, a note in the issues is welcome.
The site is four files, so any static host will do. For Cloudflare Workers the config is in
wrangler.jsonc — a static-assets-only Worker with no server-side code at all.
The one trap worth documenting: the assets directory is the repository root, and by default that
means everything gets uploaded, including the node_modules wrangler installs for itself
during the build. One of those files, workerd, is 122 MiB against a 25 MiB per-asset limit, so
the deploy fails outright. .assetsignore therefore denies everything and then names the four
files the page needs:
/*
!/index.html
!/style.css
!/script.js
!/assetsAnything new that should be served has to be added there deliberately, which is the point.
Because a web page is not the only thing a web page can draw on. Every one of these channels is a normal, documented browser API doing exactly what it was built to do — just not for the reason it was built.
MIT. The Tetris rules implemented here follow the public Tetris Guideline; Tetris is a trademark of the Tetris Holding, LLC. This is an unaffiliated hobby project.
