Skip to content

Repository files navigation

Ghost Runtime

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.

The tab icon plays Tetris while the tab title and the address bar show the stack skyline. The page below stays blank.

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.


Where the game actually is

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.

Quick start

git clone https://github.com/Wasserpuncher/ghost-runtime.git
cd ghost-runtime
# no install step, there is nothing to install

Open index.html in a browser. Then:

  1. Look at the tab icon. That is the game, playing itself.
  2. Press a key — you take over, and the sound switches on.
  3. 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 file

Controls

The 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.

Console API

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 snapshot

How it works

flowchart 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"]
Loading

The favicon is a framebuffer

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.

Deterministic simulation, and replays that are three lines long

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 machine

The 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.

The autopilot

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.

The chiptune engine

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.

The empty page as a media source

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.

The README animation makes itself

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 frames

Testing

69 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.

Project layout

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

Browser support

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.

Deploying

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
!/assets

Anything new that should be served has to be added there deliberately, which is the point.

Why

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.

License

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.

About

A complete Tetris that renders nowhere: the page stays empty and the game runs in the favicon, the tab title, the address bar, the console and the OS media panel. Zero dependencies.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages