zx-kit
v0.32.1
Published
A Speccy-flavoured fantasy toolkit for tiny TypeScript browser games
Readme
zx-kit
A Speccy-flavoured fantasy toolkit for tiny TypeScript browser games. Inspired by the ZX Spectrum — not an emulator, not a hardware clone.
Spectrum-palette canvas rendering. ROM bitmap font. AY-3-8912 three-channel audio. Beeper SFX. Tile maps. Free-roaming sprites. Collision detection. Saves. Camera. Scene manager. Particle pool. Dithered lighting. Offscreen layer cache. Authentic attribute clash. Monochrome playfield. Zero dependencies. TypeScript-first.
Why zx-kit?
The ZX Spectrum was a marvel of constraint: its 8×8 pixel grid, 15-color palette, and 1-bit beeper defined an entire visual and sonic language. Thousands of games were made with nearly nothing — and they were unforgettable.
zx-kit captures that aesthetic in TypeScript. You get the Spectrum's palette, ROM font, 8×8 cell thinking, beeper sounds, and AY-style chiptune audio — but without the hardware prison. Sprites keep their own colors. Lighting is smooth. Saves work. Mouse and gamepad are supported. The 256×192 canvas is a soft constraint, not a law.
Think of it as a tiny fantasy console in the spirit of the ZX Spectrum — not an emulator, not a hardware clone, but a tool that lets that aesthetic live in modern TypeScript games.
Key Features
- AY-3-8912 Melodik emulator — three independent square-wave channels, LFSR noise generator, all 16 hardware envelope shapes, logarithmic amplitude table accurate to the real chip
- ZX Spectrum ROM font — all 96 printable ASCII characters, 8×8 pixels, byte-for-byte faithful to the original ROM
- Authentic 15-color palette — normal and bright variants, palette-enforced at compile time via the
SpectrumColortype - Canvas renderer — pixel-perfect scaled rendering, sprite flipping, text drawing, CRT scanline overlay, animated border flashing
- Tile map engine — scrollable maps, O(1) id-index, smart seasonal background swapping, solid-tile collision queries
- Offscreen layer cache — render a static or rarely-changing layer (tile map, CRT overlay) once to an offscreen canvas and blit it each frame;
dirty-flag invalidation turns thousands of per-pixelfillRects into a singledrawImage - Authentic attribute clash (opt-in) — a 32×24 cell ink/paper screen that reproduces the real Spectrum colour bleed when a sprite and the background share an 8×8 cell; resolved to one
putImageData/frame. Off by default, on when you want it - Monochrome playfield (opt-in) — the classic anti-clash trick: render the action area in a single ink + paper at its own size, keep the colour in the HUD around it. Everything inside becomes a clean two-colour silhouette — no clash, ever
- Free-roaming sprites — position, velocity, gravity,
flipXcaching, transparent or opaque background - Three-tier collision — AABB overlap tests, generic rect-vs-tile wall resolution (any sprite size), and pixel-precise mask overlap with O(pixels) sorted-merge intersection — no allocations per frame
- Keyboard and gamepad input — configurable key-repeat, transparent gamepad polling, single-consume action flags, instant state reset on phase transitions
- ZX-style UI widgets — progress bars with managed lifetime, boxes, frames, panel titles
- Typed save / load — persistent saves via
localStoragewith schema versioning, migrations, slot enumeration, in-memory throttling, and discriminated Result types for every failure mode - Runtime locale switching — type-safe string-pack selection via
pickLocale(), so a game can switch language while running — unimaginable on the original Spectrum, natural in the browser - Zero dependencies — only Web platform APIs:
Canvas,Web Audio,KeyboardEvent,Gamepad - Tree-shakeable —
sideEffects: false, so unused modules are dropped from your production bundle - TypeScript-first — strict mode, full
.d.tsdeclarations, noany
Spectrum-inspired, not hardware-accurate by default
zx-kit is not a ZX Spectrum emulator, and the default renderer does not model the hardware attribute clash (where every 8×8 cell can hold only one ink/paper pair), the ULA timing, or the Z80 memory layout. The default path composites in full colour, so sprites keep their own colours and never bleed into the background.
What it does model is the aesthetic discipline of the Spectrum:
- 256×192 canvas (soft constraint — you can go larger)
- 15-color palette, compile-time enforced via
SpectrumColor - 8×8 cell rhythm for tiles, sprites, and UI
- ROM-accurate font (byte-for-byte from the original ROM)
- Monochromatic bitmap sprites
- Beeper-style 1-bit SFX
- AY-3-8912-style three-channel chiptune audio
Want the real thing? Three opt-in rendering paths cover the spectrum from fantasy to faithful:
| Path | Module | Look |
|------|--------|------|
| Fantasy (default) | renderer | Full-colour compositing — sprites keep their colours, no bleed. Best for readability. |
| Authentic clash | attrscreen | 1-bit pixels + a 32×24 ink/paper grid: real per-cell colour bleed when a sprite and the background share an 8×8 cell. |
| Anti-clash | monoscreen | One ink/paper for the whole playfield — clash-proof monochrome action, with a colourful HUD around it. |
A white hero walking past a green plant stays white under the default renderer, bleeds the shared cell under attrscreen, and is a clean silhouette under monoscreen — your choice, per game or per in-game toggle.
Examples
Minefield — ZX Spectrum Minesweeper — live demo built entirely with zx-kit.
The repository also includes small static examples that import ../../dist/index.js
directly, so each one doubles as a browser-checkable API recipe:
| Example | Shows |
|---------|-------|
| examples/ay-music/ | AY channels A/B/C plus beeper SFX as a four-voice Spectrum-style setup |
| examples/pixel-collision/ | AABB false positives vs bitmapPixelMask() / masksOverlap() |
| examples/particles/ | Allocation-free particle pools for sparks, smoke, and explosions |
| examples/i18n-runtime/ | Runtime language switching with pickLocale() and persisted preference |
| examples/bitmap-attrs/ | Bitmap, AttrMap, mirroring, colour clash, and inkOnly rendering |
| examples/save-slots/ | Save profiles, auto/manual slots, latest-slot restore, throttling, and delete |
Build first with npm run build, then serve the repository root and open any
example path in the browser.
Installation
From npm (recommended)
npm install zx-kitThen import directly — no Vite alias, no path mapping, no bundler configuration required:
import { setupCanvas, C, CELL, initAudio, playAY, initInput } from 'zx-kit'The package ships compiled JavaScript (dist/) with full TypeScript declarations.
From source (local / offline development)
Clone the repository and link it into your project:
# 1. Clone and build zx-kit
git clone https://github.com/zrebec/zx-kit.git
cd zx-kit
npm install
npm run build
# 2. In your game project — install from local path
npm install ../zx-kitUse
npm install ../zx-kit --prefer-onlineif npm caches the local path aggressively. Switch back to the npm version any time:npm install zx-kit@latest
Quick Start
A game loop in under 30 lines:
import {
setupCanvas, C, CELL,
drawText, drawSprite,
initAudio, createAY,
initInput, tickMovement,
} from 'zx-kit'
const canvas = document.getElementById('game') as HTMLCanvasElement
const ctx = setupCanvas(canvas, 4) // 256×192 game px → 1024×768 CSS px
initInput()
// Audio must start inside a user gesture (browser policy)
let ay: ReturnType<typeof createAY> | null = null
window.addEventListener('keydown', () => {
initAudio()
ay = createAY()
ay.tone('A', 440, 10) // start a tone on channel A
}, { once: true })
const PLAYER = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
let px = 120, py = 88
let last = performance.now()
function loop(now: number) {
const dt = now - last; last = now
const dir = tickMovement(dt)
if (dir === 'left') px -= 1
if (dir === 'right') px += 1
if (dir === 'up') py -= 1
if (dir === 'down') py += 1
ctx.fillStyle = C.BLACK
ctx.fillRect(0, 0, 256, 192)
drawText(ctx, 'ZX-KIT', 0, 0, C.B_GREEN, C.BLACK)
drawSprite(ctx, PLAYER, px, py, C.B_CYAN, C.BLACK)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)Getting Started — Build Your First Game
This tutorial walks you through building a working game from scratch: a character you can move around the screen with arrow keys, animated walking frames, and a sound effect on every step.
No prior game development experience needed. You need basic JavaScript/TypeScript knowledge (variables, functions, arrays).
What you will need
| Tool | Where to get it | Why | |------|----------------|-----| | Node.js 22+ | nodejs.org | Runs npm — the package manager we use to install zx-kit | | A code editor | code.visualstudio.com (free) | Edits your source files | | A terminal | Built into macOS/Linux; use PowerShell on Windows | Runs commands |
Step 1 — Create the project
Open a terminal and run:
mkdir my-first-game
cd my-first-game
npm init -ynpm init -y creates a package.json file — the project's identity card. The -y flag accepts all defaults so you don't have to answer questions.
Step 2 — Install dependencies
npm install zx-kit
npm install --save-dev vite- zx-kit — the game engine you are building with
- vite — a development server that reloads the browser whenever you save a file (installed as a dev tool, not part of your shipped game)
Step 3 — Configure package.json
Open package.json and replace it with the following. The two key additions are "type": "module" (enables modern JavaScript imports) and the scripts section (adds the npm run dev command):
{
"name": "my-first-game",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"zx-kit": "^0.31.1"
},
"devDependencies": {
"vite": "^6.0.0"
}
}Step 4 — Create index.html
Create a file called index.html in your project root:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>My First Game</title>
<style>
body {
margin: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>The <canvas> is the game screen. The <script> tag loads your game code.
Step 5 — Create the game
Create the folder src/ and inside it a file called main.ts. We will build it one piece at a time.
5a — Canvas setup
A canvas element is just a rectangle of pixels in the browser. setupCanvas configures it for pixel-perfect ZX Spectrum-style rendering and returns a drawing context you use to paint everything.
import { setupCanvas, C, CELL } from 'zx-kit'
const canvas = document.getElementById('game') as HTMLCanvasElement
const ctx = setupCanvas(canvas, 4)
// The screen is 256 × 192 game pixels, scaled up 4× in the browser.
// From here on draw everything in game pixels — setupCanvas handles the scale.C is the color palette. CELL is the size of one sprite: 8 pixels.
5b — Define a sprite
Every character and object in zx-kit is an 8×8 pixel bitmap. You define it as 8 numbers, one per row. Each number is 8 bits — one bit per pixel. Bit 7 is the leftmost pixel.
The binary literal 0b00111100 is the same as the number 60, but written out as ones and zeros so you can see the pixel pattern directly.
We will use two walking frames so the character animates when it moves:
// 76543210 ← bit position (7 = leftmost pixel)
const WALK_A = new Uint8Array([
0b00111100, // ..####.. head
0b01111110, // .######.
0b00011000, // ...##... neck
0b01111110, // .######. arms + body
0b00011000, // ...##... waist
0b01011010, // .#.##.#. legs apart
0b01000010, // .#....#. feet
0b00000000, // ........
])
const WALK_B = new Uint8Array([
0b00111100, // ..####.. head
0b01111110, // .######.
0b00011000, // ...##... neck
0b01111110, // .######. arms + body
0b00011000, // ...##... waist
0b00111100, // ..####.. legs together
0b00011000, // ...##...
0b00000000, // ........
])
const FRAMES = [WALK_A, WALK_B] // frame 0 = legs apart, frame 1 = legs together5c — Input
initInput() attaches keyboard listeners. Call it once at startup, not inside the game loop.
isHeld(key) returns true while a key is pressed. We use it to check whether an arrow key is being held down each frame.
import { initInput, isHeld } from 'zx-kit'
initInput()5d — Audio
Browsers will not play any sound until the user has interacted with the page (clicked, tapped, or pressed a key). This is a browser security rule — there is nothing we can do to bypass it. The pattern below waits for the first keydown and initialises audio then:
import { initAudio, getAudioContext, resumeAudio, beep } from 'zx-kit'
window.addEventListener('keydown', () => initAudio(0.3), { once: true })
// initAudio(0.3) = master volume 30%. Called at most once thanks to { once: true }.5e — Player state and animation
import { createAnimation, tickAnimation } from 'zx-kit'
let px = 120 // player x position in game pixels
let py = 88 // player y position
const SPEED = 60 // pixels per second
// A 2-frame looping animation, 150ms per frame = one full step every 300ms
const walkAnim = createAnimation(2, 150)
let stepTimer = 0 // footstep sound timer5f — The game loop
Every frame the browser calls loop. Inside, we:
- Calculate
dt— how many milliseconds passed since last frame - Move the player based on held keys
- Keep the player inside the screen
- Pick the right animation frame
- Play a footstep sound periodically while moving
- Clear the screen and redraw everything
import { drawSprite, drawText } from 'zx-kit'
let lastTime = 0
function loop(now: number): void {
const dt = Math.min(now - lastTime, 100)
// dt is milliseconds since the last frame (usually ~16ms at 60fps).
// We cap at 100ms so a background tab returning doesn't cause a huge position jump.
lastTime = now
// ── Move player ────────────────────────────────────────────────────────────
let moving = false
if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
// SPEED (60) is pixels per second. dt is milliseconds. Divide by 1000 to convert.
// Keep inside the 256×192 canvas
px = Math.max(0, Math.min(256 - CELL, px))
py = Math.max(0, Math.min(192 - CELL, py))
// ── Animation ──────────────────────────────────────────────────────────────
// tickAnimation advances the timer and returns the current frame index (0 or 1).
// When standing still we always use frame 0.
const frame = moving ? tickAnimation(walkAnim, dt) : 0
// ── Footstep sound ─────────────────────────────────────────────────────────
if (moving) {
stepTimer -= dt
if (stepTimer <= 0) {
stepTimer = 250 // play a sound every 250ms while moving
const audio = getAudioContext()
if (audio) {
resumeAudio() // un-suspend if the tab was hidden
beep(220, 30, audio.currentTime) // 220 Hz, 30ms — a short thud
}
}
} else {
stepTimer = 0 // reset so next movement starts immediately
}
// ── Draw ───────────────────────────────────────────────────────────────────
ctx.fillStyle = C.BLACK
ctx.fillRect(0, 0, 256, 192) // clear the whole screen
drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
// ↑ position ↑ ink color ↑ paper (background)
drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
// drawText draws one ASCII character per 8px slot, left-to-right.
requestAnimationFrame(loop) // ask the browser to call us again next frame
}
requestAnimationFrame(loop) // kick off the first frameStep 6 — Run the game
npm run devOpen http://localhost:5173 in your browser. Press an arrow key. Your character walks.
Complete file
The full src/main.ts all in one place:
import {
setupCanvas, C, CELL,
drawSprite, drawText,
initInput, isHeld,
initAudio, getAudioContext, resumeAudio, beep,
createAnimation, tickAnimation,
} from 'zx-kit'
// ── Canvas ────────────────────────────────────────────────────────────────────
const canvas = document.getElementById('game') as HTMLCanvasElement
const ctx = setupCanvas(canvas, 4)
// ── Sprites ───────────────────────────────────────────────────────────────────
const WALK_A = new Uint8Array([
0b00111100, // ..####.. head
0b01111110, // .######.
0b00011000, // ...##... neck
0b01111110, // .######. arms + body
0b00011000, // ...##... waist
0b01011010, // .#.##.#. legs apart
0b01000010, // .#....#. feet
0b00000000, // ........
])
const WALK_B = new Uint8Array([
0b00111100, // ..####.. head
0b01111110, // .######.
0b00011000, // ...##... neck
0b01111110, // .######. arms + body
0b00011000, // ...##... waist
0b00111100, // ..####.. legs together
0b00011000, // ...##...
0b00000000, // ........
])
const FRAMES = [WALK_A, WALK_B]
// ── Input ─────────────────────────────────────────────────────────────────────
initInput()
// ── Audio ─────────────────────────────────────────────────────────────────────
window.addEventListener('keydown', () => initAudio(0.3), { once: true })
// ── Player state ──────────────────────────────────────────────────────────────
let px = 120
let py = 88
const SPEED = 60 // pixels per second
const walkAnim = createAnimation(2, 150)
let stepTimer = 0
// ── Game loop ─────────────────────────────────────────────────────────────────
let lastTime = 0
function loop(now: number): void {
const dt = Math.min(now - lastTime, 100)
lastTime = now
let moving = false
if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
px = Math.max(0, Math.min(256 - CELL, px))
py = Math.max(0, Math.min(192 - CELL, py))
const frame = moving ? tickAnimation(walkAnim, dt) : 0
if (moving) {
stepTimer -= dt
if (stepTimer <= 0) {
stepTimer = 250
const audio = getAudioContext()
if (audio) { resumeAudio(); beep(220, 30, audio.currentTime) }
}
} else {
stepTimer = 0
}
ctx.fillStyle = C.BLACK
ctx.fillRect(0, 0, 256, 192)
drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)What to try next
Change the sprite. Edit the binary rows in WALK_A / WALK_B — each 1 is a pixel, each 0 is background. Draw a spaceship, a gem, or a face.
Change the color. Replace C.B_CYAN with any palette color: C.B_GREEN, C.B_YELLOW, C.B_RED, C.B_MAGENTA, C.B_WHITE. The full list is in the palette reference.
Add a second character. Copy the player variables (px2, py2, walkAnim2) and add W A S D controls using isHeld('w') etc.
Add obstacles. Use createTileMap to place solid wall tiles and resolveX / resolveY to stop the player at them.
Add chiptune music. Call playAY() with a note array to play a three-channel melody — see ay.ts.
Study a complete game. Minefield is built entirely with zx-kit. Every mechanic in this tutorial — sprites, input, animation, audio, tilemap — appears there in a production context.
Modules
| Module | What it provides |
|--------|-----------------|
| ay.ts | AY chip emulator: 3-channel tone, LFSR noise, 16 envelope shapes |
| renderer.ts | Canvas setup, 8x8 sprites, arbitrary-size bitmaps, attribute maps, text, scanlines, border flash |
| audio.ts | 1-bit beeper: square-wave notes, patterns, volume control |
| ui.ts | Boxes, frames, panel titles, progress bars + instrumentation widgets (dotted grids, segmented bars, fluid tanks, dials, text compass) |
| input.ts | Keyboard/gamepad movement, key-repeat, action flags, state reset |
| sprite.ts | Sprites: position, velocity, gravity, flip, render |
| collision.ts | AABB overlap + rect-based tile resolution, pixel-precise mask overlap and tile checks |
| animation.ts | Frame-timer for sprite strips, position tween between two points |
| camera.ts | Viewport that follows a target with lerp + deadzone, world-bounds clamping |
| scene.ts | Stack-based scene manager with onEnter/onExit/onPause/onResume hooks |
| save.ts | Typed save/load via callbacks, versioning + migrations, slot enumeration, throttling, Result types |
| tilemap.ts | Scrollable maps, solid tiles, O(1) id-index, background swap |
| tilescroll.ts | Pixel-smooth tile-map rendering at any camera position (sub-tile scroll) |
| particles.ts | Allocation-free particle pool for pixel effects: sparks, dust, puffs |
| rng.ts | Seeded deterministic PRNG (mulberry32): int/range/float/chance/pick/shuffle/fork |
| palette.ts | 15 Spectrum colors, SpectrumColor type, CELL, SCALE |
| font.ts | 96-character ROM font, raw bitmap access |
| i18n.ts | Type-safe runtime locale selection for translated string packs |
| lighting.ts | Dithered cave darkness: pre-baked level tiles + dirty-cell buffer, one blit/frame (no per-frame putImageData) |
| cache.ts | Offscreen layer cache: render a static layer once, blit each frame, dirty-flag invalidation |
| attrscreen.ts | Opt-in authentic ZX colour clash: 1-bit pixels + 32×24 per-cell ink/paper, one putImageData/frame |
| monoscreen.ts | Opt-in monochrome playfield (own size): 1-bit mask + one ink/paper, blitted at an offset — clash-proof |
| music.ts | Write AY music by note name (A5, C#4) and loop it for background tracks |
Audio architecture — beeper vs AY
zx-kit ships two independent audio modules — audio.ts (the beeper) and ay.ts (the AY chip). They are not alternatives — most ZX Spectrum 128K games used both at once, and so should yours.
The history (so the choice makes sense)
| Hardware | Beeper (1-bit) | AY-3-8912 (3 ch) | |----------|:--:|:--:| | Spectrum 48K | ✅ built-in | ❌ | | Spectrum 128K / +2 / +3 | ✅ built-in | ✅ built-in | | Melodik add-on (for 48K) | — | ✅ |
- 48K games (Manic Miner, Jet Set Willy, Atic Atac) had only the beeper — every blip, jump, footstep and title jingle was a square wave forced out of the 1-bit speaker by tight CPU loops.
- 128K games (Robocop, R-Type, Chase H.Q., Lord of the Rings) used the AY for music — proper 3-channel tunes with envelope shaping — while the beeper kept doing sound effects in parallel. AY hummed an orchestral score; the beeper still went pew pew.
When to use which
| Want to play… | Module | Function | Why |
|---|---|---|---|
| Short SFX (shot, jump, hit, beep) | audio.ts | beep(freq, dur, t) | Single square wave, punchy, era-correct for SFX |
| A 3-channel jingle / chord | ay.ts | playAY({ a, b, c }) | Needs ≥2 simultaneous voices |
| Game-over fanfare / level music | ay.ts | playAY(...) | Envelope shaping + multiple voices |
| Single-voice melody | audio.ts | playPattern(notes) | Lighter setup, no AY init needed |
| Live, dynamically-changing tone (siren, engine) | ay.ts | createAY() then tone() | Persistent oscillator handle |
| Title-screen music | ay.ts | playAY(...) | Authentic 128K title-music feel |
Rule of thumb: if it needs to be heard at the same time as something else, you almost certainly want AY for at least one of the two.
Authentic parallel pattern — the "Robocop" pattern
This is how 128K games actually sounded:
import { initAudio, beep, getAudioContext, resumeAudio } from 'zx-kit' // beeper
import { playAY } from 'zx-kit' // AY
// One-time setup (must be inside a user gesture — click, keydown — due to browser autoplay policy)
window.addEventListener('keydown', () => { initAudio(); resumeAudio() }, { once: true })
// Title screen: AY plays a multi-voice melody...
playAY({
a: [{ freq: 523, dur: 200 }, { freq: 659, dur: 200 }, { freq: 784, dur: 400, envShape: 12, envCycleDurMs: 200 }],
b: [{ freq: 262, dur: 200 }, { freq: 330, dur: 200 }, { freq: 392, dur: 400 }],
})
// ...meanwhile in the game loop, beeper does the SFX:
function onPlayerShoots() {
const audio = getAudioContext()
if (audio) beep(1200, 40, audio.currentTime) // sharp pew
}
function onPlayerHit() {
const audio = getAudioContext()
if (audio) beep(120, 200, audio.currentTime) // low thump
}Both modules route through the same master GainNode, so setMasterVolume(v) controls both at once. They share state cleanly — no audio bus conflicts.
Notes on accuracy
- Beeper (
audio.ts) is a faithful 1-bit-style square wave via Web Audio'sOscillatorNode. Era-correct for SFX use. - AY (
ay.ts) is a good approximation of the AY-3-8912 — hardware-accurate logarithmic amplitudes (16 levels, ≈ √2 ratio), all 16 envelope shapes, proper LFSR noise. Not sample-accurate: Web Audio'sOscillatorNodeis band-limited (no aliasing artefacts), real AY's raw squares have a buzzier, fuzzier character; envelopes are smooth ramps here vs the chip's 16-step ramps. For chip-tune purists wanting bit-exact AY emulation, a future AudioWorklet-based backend is on the roadmap. For game sound and most music, the current implementation is more than convincing.
ay.ts — AY-3-8912 Melodik Audio
The AY-3-8912 chip (sold as the Melodik add-on for ZX Spectrum 48K, built into the 128K) gave the Spectrum three independent square-wave channels, a shared LFSR noise generator, and a hardware envelope generator with 16 distinct shapes. This module emulates all of it via the Web Audio API with hardware-accurate logarithmic amplitude values.
Pair with
audio.ts(the beeper) for sound effects. Use AY for music, beeper for SFX — see Audio architecture — beeper vs AY for the historical context and the parallel-use pattern. Both modules share the same master gain, sosetMasterVolume()controls them together.
Two usage modes:
| Mode | Function | Use case |
|------|----------|----------|
| Real-time | createAY() | Persistent chip handle — set channels live (SFX, dynamic music) |
| Sequencer | playAY(pattern) | Pre-scheduled, fire-and-forget (music tracks, jingles) |
Both modes route through the zx-kit master GainNode, so setMasterVolume() works globally.
AY_CLOCK
export const AY_CLOCK = 1_773_400 // Hz — ZX Spectrum 128K / MelodikThe AY-3-8912 master clock. Exported for use in frequency calculations:
f_Hz = AY_CLOCK / (16 × period_register).
AY_VOL
export const AY_VOL: readonly number[] = [
0, 0.0089, 0.0118, 0.0156, 0.0211, 0.0289, 0.0403, 0.0549,
0.0744, 0.1060, 0.1518, 0.2139, 0.2969, 0.4259, 0.6098, 1.0,
]Hardware-accurate logarithmic amplitude table. Each step ≈ √2 (3 dB), matching the real chip's resistor ladder. Index 0 = silence, index 15 = full amplitude.
AY_ENVELOPE_SHAPES
export const AY_ENVELOPE_SHAPES: readonly string[]Human-readable names for all 16 R13 envelope shapes — useful for documentation, tooling, and debugging.
| R13 | Shape | Description |
|-----|-------|-------------|
| 0–3 | \_ | One-shot decay, hold at zero |
| 4–7 | /_ | One-shot attack, hold at zero |
| 8 | \\\\ | Repeat decay (sawtooth down) |
| 9 | \_ | One-shot decay, hold at zero |
| 10 | \/\/ | Alternate down/up (triangle) |
| 11 | \‾ | One-shot decay, hold at maximum |
| 12 | // | Repeat attack (sawtooth up) |
| 13 | /‾ | One-shot attack, hold at maximum |
| 14 | /\/\| Alternate up/down (triangle) |
| 15 | /_ | One-shot attack, hold at zero |
AYChannel type
type AYChannel = 'A' | 'B' | 'C'AYNote interface
interface AYNote {
freq: number // Hz — 0 = rest
dur: number // milliseconds
vol?: number // 0–15 (default 15). Ignored when envShape is set.
noise?: boolean // mix LFSR noise alongside tone (default false)
noisePeriod?: number // 1–31 — higher = darker texture (default 8)
envShape?: number // 0–15 (R13) — activates envelope, overrides vol
envCycleDurMs?: number // ms for one ramp (15→0 or 0→15). Default = note duration.
}AYChip interface
The handle returned by createAY().
interface AYChip {
tone(ch: AYChannel, freq: number, vol?: number): void
enableNoise(ch: AYChannel, period?: number): void
disableNoise(ch: AYChannel): void
envelope(ch: AYChannel, shape: number, cycleDurMs: number): void
mute(ch: AYChannel): void
muteAll(): void
stop(): void
}createAY(): AYChip
Creates three persistent AY channels wired to the master gain. Each channel has:
- An independent square-wave oscillator (tone)
- An LFSR noise path (shared 17-bit noise source, per-channel lowpass filter and gain)
AudioParamautomation for envelope
Must be called inside a user-gesture handler.
button.addEventListener('click', () => {
initAudio()
const ay = createAY()
// Simple tone
ay.tone('A', 440, 12) // channel A: A4, amplitude level 12
// Tone + noise mix
ay.tone('B', 220, 10)
ay.enableNoise('B', 16) // darker noise (higher period = lower cutoff)
// Envelope — shape 10 = \/\/ triangle, 400ms cycle
ay.tone('C', 110, 0) // oscillator active but tone gain is silent
ay.envelope('C', 10, 400) // envelope drives the amplitude
setTimeout(() => ay.muteAll(), 3000)
setTimeout(() => ay.stop(), 3500)
})ay.tone(ch, freq, vol?)
Sets the channel oscillator frequency and amplitude. freq ≤ 0 silences the tone generator (noise can still run). vol maps to AY_VOL (0–15, default 15). Cancels any running envelope on that channel.
ay.enableNoise(ch, period?)
Enables LFSR noise on a channel. period 1–31 maps to AY_CLOCK / (16 × period) Hz as a lowpass cutoff on the noise path. Default period 8 → ~13 kHz (bright, crispy). Period 28 → ~4 kHz (darker, rumble-like).
ay.disableNoise(ch)
Fades noise out on a channel with a 5ms release.
ay.envelope(ch, shape, cycleDurMs)
Applies an AY hardware envelope to a channel's amplitude. shape 0–15 corresponds to the 16 R13 values. cycleDurMs is the duration of one ramp (0→15 or 15→0). Repeating shapes (8, 10, 12, 14) are pre-scheduled for 32 cycles; call again to extend.
// Explosion: channel C, shape 8 (repeat decay), 60ms per cycle
ay.enableNoise('C', 5)
ay.envelope('C', 8, 60)
// Organ: shape 13 (/‾ fast attack, hold high), 20ms attack
ay.tone('A', 523, 0)
ay.envelope('A', 13, 20)ay.mute(ch) / ay.muteAll()
Fade out one or all channels (5ms release). Cancels any pending envelope automation.
ay.stop()
Stops all oscillators and the noise source, disconnects all Web Audio nodes. Call when discarding the chip instance.
playAY(pattern, startDelay?): void
Pre-schedules up to three independent note arrays on the shared AudioContext. All channels start at the same wall-clock time. Fire-and-forget — no handle returned. Per-note noise and envelope are fully supported.
// Three-channel chiptune jingle with envelope and noise
playAY({
a: [
{ freq: 523, dur: 300, envShape: 13, envCycleDurMs: 20 }, // C5, organ attack
{ freq: 659, dur: 300, envShape: 13, envCycleDurMs: 20 }, // E5
{ freq: 784, dur: 600, envShape: 12, envCycleDurMs: 100 }, // G5, sawtooth swell
],
b: [
{ freq: 261, dur: 600, vol: 10 }, // C4 bass note
{ freq: 329, dur: 600, vol: 10 }, // E4
],
c: [
{ freq: 0, dur: 100, noise: true, noisePeriod: 5, envShape: 8, envCycleDurMs: 40 }, // snare hit
{ freq: 0, dur: 1100 }, // silence
],
})
// With a 500ms startup delay
playAY({ a: melody, b: bass }, 500)renderer.ts — Canvas Renderer
All drawing functions operate in game pixels. setupCanvas applies ctx.scale(scale, scale) so every call uses the ZX Spectrum's native coordinate space. Every ink/paper parameter is SpectrumColor — the compiler enforces the palette.
setupCanvas(canvas, scale, width?, height?): CanvasRenderingContext2D
One-call canvas initialization. Sets dimensions, CSS size, disables smoothing, applies scale transform.
scale— CSS pixels per game pixel.4= standard ZX display (256×192 → 1024×768)width— game pixels wide, default256height— game pixels tall, default192
const ctx = setupCanvas(canvas, 4) // standard 256×192
const ctx = setupCanvas(canvas, 4, 256, 208) // +2 extra rows for status bar
const ctx = setupCanvas(canvas, 3) // 768×576 CSS — smaller screenmirrorSprite(src): Uint8Array
Flips an 8-byte sprite horizontally. Returns a new Uint8Array — the original is not modified. The result is cache-friendly: call once and store both orientations.
export const PLAYER_RIGHT = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)drawSprite(ctx, sprite, x, y, ink, paper): void
Draws an 8×8 bitmap at game coordinates. Always paints the paper background first. ink and paper must be SpectrumColor values.
drawSprite(ctx, MINE_SPRITE, col * CELL, row * CELL, C.B_RED, C.BLACK)
drawSprite(ctx, GEM_SPRITE, col * CELL, row * CELL, C.B_CYAN, C.BLACK)
drawSprite(ctx, DOOR_SPRITE, col * CELL, row * CELL, C.YELLOW, C.B_BLUE)drawChar(ctx, charCode, x, y, ink, paper?): void
Draws one ASCII character from the ROM font. Omit paper for a transparent background (only ink pixels are drawn).
drawChar(ctx, 127, x, y, C.B_GREEN, C.BLACK) // solid block █
drawChar(ctx, 'A'.charCodeAt(0), x, y, C.B_WHITE) // transparent bgdrawText(ctx, text, x, y, ink, paper?): void
Draws a string left-to-right, one character per CELL-wide slot.
drawText(ctx, 'SCORE:00000', 0, statusY, C.B_WHITE, C.BLACK)
drawText(ctx, 'PRESS ANY KEY', x, y, C.B_YELLOW) // transparent bgdrawTextCentered(ctx, text, y, cols, ink, paper?): void
Centers a string within cols character columns.
// Bind the column count once to keep call sites clean
const print = (text: string, y: number, ink: SpectrumColor) =>
drawTextCentered(ctx, text, y, 32, ink)
print('GAME OVER', 88, C.B_RED)
print('PRESS ANY KEY', 104, C.B_WHITE)flashBorder(color, times, intervalMs, resetColor?): void
Animates document.body.style.backgroundColor. Fire-and-forget — does not block. Each call cancels any in-flight flash (no overlapping intervals). Always resets to resetColor when the sequence completes.
resetColordefaults toC.BLACK
flashBorder(C.B_RED, 3, 150) // 3 red flashes → black (explosion)
flashBorder(C.B_GREEN, 2, 200) // level complete
flashBorder(C.B_CYAN, 2, 120, C.B_BLUE) // flash → reset to blue borderdrawScanlines(ctx, width?, height?, alpha?): void
Draws a CRT scanline overlay. Every even row gets a semi-transparent black stripe. Pass the same width/height as setupCanvas, or omit to use the defaults (256×192).
// At the end of each frame, after all game content:
drawScanlines(ctx) // standard 256×192, alpha=0.18
drawScanlines(ctx, 256, 208) // taller canvas
drawScanlines(ctx, 256, 192, 0.25) // darker scanlinescurveDisplay(ctx, width?, height?, strength?): void
Applies a CRT barrel-distortion warp to the canvas content using a temporary off-screen canvas and a quadraticCurveTo warp. Gives the display a subtle CRT monitor feel.
// Last step, after drawScanlines:
curveDisplay(ctx) // default strength
curveDisplay(ctx, 256, 208, 6) // stronger warpBitmap interface
An arbitrary-size monochrome bitmap. Width must be a positive multiple of 8 so
each row is byte-aligned. data is row-major; bit 7 is the leftmost pixel in
each byte.
interface Bitmap {
data: Uint8Array
width: number
height: number
}Use Bitmap for 16x16 enemies, 16x24 heroes, 32x32 bosses, tall objects, and
anything that outgrows the classic 8x8 drawSprite() format.
createBitmap(data, width, height): Bitmap
Builds a Bitmap from packed bytes and validates the dimensions and byte
count immediately. Throws if width is not byte-aligned, height is invalid, or
the data length does not match (width / 8) * height.
const HERO = createBitmap(new Uint8Array([
0x03, 0xC0,
0x07, 0xE0,
// ...22 more 16px-wide rows
]), 16, 24)createBitmapFromRows(rows): Bitmap
Builds an arbitrary-size Bitmap from readable pixel-art rows instead of
hand-packed bytes. This is useful for sprites larger than 8x8 where hex arrays
become hard to review.
Xor#= solid pixel.or space = transparent pixel- every row must have the same width
- width must be a positive multiple of 8
const TRUCK = createBitmapFromRows([
'....XXXXXXXX....',
'..XXXXXXXXXXXX..',
'.XXXX......XXXX.',
'XXXXXXXXXXXXXXXX',
'XX..XXXXXXXX..XX',
'XX............XX',
'..XXX......XXX..',
'................',
])
drawBitmap(ctx, TRUCK, x, y, C.B_WHITE)The returned object is the same Bitmap shape produced by createBitmap(), so
it works with drawBitmap, drawBitmapAttrs, mirrorBitmap, and collision
helpers such as bitmapPixelMask.
drawBitmap(ctx, bitmap, x, y, ink, paper?, inkOnly?): void
Draws an arbitrary-size Bitmap. The colour model has three modes, ordered by how much of the background they disturb:
| Call | Paints | Touches the background? |
|------|--------|-------------------------|
| drawBitmap(ctx, bmp, x, y, ink) | only the set ink pixels | no — transparent overlay |
| drawBitmap(ctx, bmp, x, y, ink, paper) | a full width×height paper rectangle, then ink pixels on top | yes — the whole bounding box |
| drawBitmap(ctx, bmp, x, y, ink, paper, true) | only the set ink pixels; paper is ignored | no |
inkOnly (last parameter, default false) suppresses the paper rectangle even when a paper colour is supplied. For drawBitmap this is functionally identical to omitting paper — its value is ergonomic: keep a sprite's configured paper and toggle the opaque box on or off with a boolean, instead of conditionally choosing whether to pass the argument at the call site.
mirrorBitmap(src): Bitmap
Returns a horizontally flipped copy of a Bitmap. The original is not
modified. Use it at module load time to derive left-facing sprites from one
right-facing definition.
const HERO_RIGHT = createBitmapFromRows([...])
const HERO_LEFT = mirrorBitmap(HERO_RIGHT)AttrMap interface
Per-8x8-cell ink and paper colours for a Bitmap, mirroring the ZX Spectrum
attribute buffer. cols must match bitmap.width / 8; rows must match
bitmap.height / 8.
interface AttrMap {
readonly cols: number
readonly rows: number
readonly inks: readonly SpectrumColor[]
readonly papers?: readonly SpectrumColor[]
}Omit papers for transparent per-cell ink rendering, or provide papers for
the authentic colour-clash look.
createAttrMap(cols, rows, inks, papers?): AttrMap
Builds an AttrMap with validation. inks must contain cols * rows colours.
papers can be omitted, supplied as a matching per-cell array, or supplied as
one colour to fill every cell.
const HERO_ATTRS = createAttrMap(2, 3, [
C.B_YELLOW, C.B_YELLOW,
C.B_RED, C.B_MAGENTA,
C.B_CYAN, C.B_GREEN,
], C.BLACK)drawBitmapAttrs(ctx, bitmap, attrs, x, y, inkOnly?): void
Renders a Bitmap with a per-cell AttrMap — each 8×8 cell carries its own (ink, paper), the authentic Spectrum attribute model. Here inkOnly is not redundant: it keeps every per-cell ink colour but skips all per-cell paper fills. One fully-coloured AttrMap (with papers for the boxed look on a plain background) then renders two ways — flip inkOnly per frame, with no second paper-less map to build and keep in sync. Dimension validation still throws under inkOnly: the flag changes what is painted, never the contract.
// chaosbunny — a blue rabbit with a white belly, hopping through a dark cave:
drawBitmapAttrs(ctx, BUNNY, BUNNY_ATTRS, x, y, true)
// → per-cell blue/white inks preserved, but no black 8×8 blocks stamped onto
// the cave behind it. The rabbit reads by its own silhouette.mirrorAttrMap(attrs): AttrMap
Returns a horizontally flipped copy of an AttrMap, reversing each attribute
row. Pair it with mirrorBitmap() so a mirrored sprite keeps its colours on
the matching 8x8 cells.
const HERO_LEFT = mirrorBitmap(HERO_RIGHT)
const HERO_LEFT_ATTRS = mirrorAttrMap(HERO_RIGHT_ATTRS)Why does inkOnly exist? (and why is it, honestly, a little bit of debt?)
This is the kind of decision worth writing down, because the "obvious" answer is the wrong one.
Why was the box there in the first place? Because that is the ZX Spectrum. The real machine had a 256×192 one-bit pixel bitmap and a separate 32×24 attribute map: one ink + one paper + bright + flash per 8×8 cell, nothing finer. zx-kit's drawBitmapAttrs, and the paper argument of drawBitmap, model exactly that constraint (added in v0.19.0, "authentic Spectrum colour clash"). Fill the cell's paper, draw the ink on top. That's the look, and removing it would make the engine less of a Spectrum, not more.
So why fight it? Because the hardware had a second technique we had quietly skipped — the masked sprite. Cheap games stamped attributes and lived with "colour clash," the famous bleeding of one sprite's colours onto whatever 8×8 cell it touched. The games whose movement looked clean used a mask: a second bitmap ANDed into the screen to punch a hole in the exact shape of the sprite, then the sprite ORed in. Only the sprite's own pixels changed — no paper block, no bleed onto the neighbour. inkOnly is the modern per-pixel-canvas equivalent of a masked sprite, and on a canvas it comes almost for free: painting only the set pixels already leaves everything else untouched.
What broke without it? The same character that broke collision. Picture a Dizzy-style sprite with paper: C.BLACK drawn next to a white leaf. The visible pixels never touch the leaf — but the paper rectangle (or the 8×8 paper cell) does, and it paints the leaf's edge black. The bounding box committed the crime; the sprite took the blame. We met this exact bounding-box sin once before, in collision (v0.21.0, "the Dizzy problem"): the AABB overlapped a platform the pixels didn't. Same Dizzy, same box, different subsystem — and rendering needed the same answer collision got: stop trusting the box, trust the pixels.
Then why call it debt? Because the truly faithful fix is bigger than a boolean. A real masking layer would carry an explicit mask bitmap per sprite, or compose against an off-screen attribute buffer the way the hardware did. inkOnly is the ~12-line, zero-allocation, fully backwards-compatible shortcut to 90% of that value. For a hobby engine whose stated philosophy is less is more, that is the right call — but it is worth being honest that it is a shortcut, not the model. The day a game needs a paper silhouette that hugs the sprite outline (paper behind the shape, but not the box) is the day this flag stops being enough and the masking layer earns its place.
Tested
Both functions are covered by the renderer suite, including the new branch and its edges:
inkOnlysuppresses the paper rectangle / per-cell paper blocks (zero fills for an all-zero sprite; ink-only fills for an all-ones sprite);- per-cell ink colours survive
inkOnly; - the default (
false) still fills paper — a regression guard so the flag can never silently change an existing game; drawBitmap(..., inkOnly)matches transparent rendering, anddrawBitmapAttrs(..., inkOnly)matches a paper-lessAttrMap, pixel-for-pixel;- exception path:
drawBitmapAttrsstill throws on anAttrMap/Bitmapdimension mismatch withinkOnlyset.
audio.ts — Beeper Audio
Single-channel 1-bit square-wave audio, faithful to the ZX Spectrum beeper. Use this for sound effects (shots, jumps, hits, beeps) and simple monophonic melodies.
Pair with
ay.tsfor music. This is how 128K Spectrum games actually sounded — see Audio architecture — beeper vs AY for the reasoning and the "Robocop" parallel-use pattern.
All audio routes through a shared AudioContext and master GainNode — setMasterVolume() controls both modules at once. initAudio() must be called inside a user-gesture handler due to browser autoplay policy.
initAudio(volume?): void
Creates the AudioContext and master gain node. Idempotent — safe to call multiple times. volume is clamped to 0.0–1.0 (default 0.3).
window.addEventListener('keydown', () => initAudio(), { once: true })
window.addEventListener('click', () => initAudio(), { once: true })resumeAudio(): void
Resumes a suspended AudioContext. Browsers suspend the context on tab hide or first load. Call before scheduling any audio in the game loop.
getAudioContext(): AudioContext | null
Returns the shared context, or null before initAudio().
getMasterGain(): GainNode | null
Returns the master gain node. Connect custom oscillators here to participate in the global volume level.
getMasterVolume(): number
Returns the current master volume (0.0–1.0), or 0 before initAudio().
setMasterVolume(volume): void
Sets master volume. Clamped to 0.0–1.0. No-op before initAudio().
setMasterVolume(0.5) // 50%
setMasterVolume(0) // mute
setMasterVolume(1) // fullincreaseVolume() / decreaseVolume(): void
Adjusts master volume by ±0.1, clamped to 0.0–1.0.
Note interface
interface Note {
freq: number // Hz — 0 = rest (silence, advances timeline)
dur: number // ms
}playPattern(notes, startDelay?): void
Schedules a note sequence on the shared AudioContext. freq: 0 entries produce silence for their duration. startDelay offsets the entire pattern in milliseconds.
// Rising arpeggio
playPattern([
{ freq: 262, dur: 80 }, // C4
{ freq: 330, dur: 80 }, // E4
{ freq: 392, dur: 80 }, // G4
{ freq: 523, dur: 160 }, // C5
])
// With rest and startup delay
playPattern([
{ freq: 880, dur: 100 },
{ freq: 0, dur: 50 }, // rest
{ freq: 880, dur: 100 },
], 200)beep(freq, durationMs, startTime): void
Schedules a single square-wave note at an absolute AudioContext.currentTime. Uses a 5ms linear ramp on attack and release to avoid click artefacts. Use playPattern for sequences; use beep when you need algorithmic or sample-accurate timing.
const audio = getAudioContext()!
resumeAudio()
beep(440, 80, audio.currentTime)
beep(880, 80, audio.currentTime + 0.15) // 150ms laterui.ts — UI Widgets
High-level drawing helpers and a stateful widget system for HUD elements. All primitives operate in game pixels and enforce the Spectrum palette.
Types
BorderOptions
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| enabled | boolean | true | Set false to suppress border without removing the object |
| thickness | number | 1 | Border thickness in game pixels |
| color | SpectrumColor | parent ink | Overrides the parent function's foreground color |
| style | 'solid' \| 'dashed' | 'solid' | 'dashed' = 2 px on / 2 px off |
DrawProgressBarOptions
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| id | string | "${x},${y}" | Stable key for managed redraws |
| x | number | — | Left edge in game pixels |
| y | number | — | Top edge in game pixels |
| width | number | — | Total width (multiples of CELL = 8 recommended) |
| value | number | — | Current value |
| min | number | 0 | Empty-edge value |
| max | number | 1 | Full-edge value |
| ink | SpectrumColor | C.B_WHITE | Filled-block color |
| paper | SpectrumColor | C.BLACK | Empty-block background |
| border | BorderOptions | — | Optional border |
| visibilityLength | number | 500 | Ms to stay visible after last call; 0 = permanent |
Stateless primitives
drawBox(ctx, options): void
Fills a rectangle with paper and draws an optional border.
drawBox(ctx, {
x: 8, y: 8, width: 112, height: 40,
paper: C.BLACK, ink: C.B_WHITE,
border: { style: 'solid', thickness: 1 },
})drawFrame(ctx, options): void
Draws a border only — no background fill.
drawFrame(ctx, { x: 0, y: 0, width: 256, height: 176, color: C.B_CYAN })
drawFrame(ctx, { x: 16, y: 16, width: 64, height: 32, color: C.B_RED,
border: { style: 'dashed' } })drawPanelTitle(ctx, options): void
Renders a text strip (CELL + padding * 2 height) with optional background fill. Does not draw a surrounding container — combine with drawBox or drawFrame.
drawBox(ctx, { x: 8, y: 24, width: 128, height: 56, paper: C.BLACK })
drawPanelTitle(ctx, {
text: 'OPTIONS', x: 8, y: 24,
ink: C.B_YELLOW, paper: C.BLACK,
centered: true, width: 128,
})Instrumentation widgets (stateless)
Five stateless primitives for HUDs, dashboards and tactical displays — gauges, bars, tanks, dials, compass. Each function takes a ctx plus an options object and renders immediately. The caller drives state on every frame (no built-in animation, no internal timers). Pair with Animation / Tween from animation.ts if you want smoothed transitions.
drawDottedGrid(ctx, options): void
Options type: DrawDottedGridOptions.
Regularly-spaced dot pattern. Useful for radar / sonar screens, tactical scanner overlays, debug grids, stippled backgrounds, alien-invasion detection grids.
// Sonar background (submarine HUD)
drawDottedGrid(ctx, {
x: 8, y: 8, width: 64, height: 48,
spacing: 4, color: C.GREEN, paper: C.BLACK,
})
// Chunky 2×2 dots for tactical map overlay
drawDottedGrid(ctx, {
x: 0, y: 0, width: 256, height: 192,
spacing: 8, dotSize: 2, color: C.B_WHITE,
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| x, y, width, height | number | — | Area covered by the dot field |
| spacing | number | — | Distance between adjacent dot centres |
| color | SpectrumColor | — | Dot colour |
| paper | SpectrumColor | — | Optional background fill |
| dotSize | number | 1 | Dot size in pixels (use 2 for chunkier dots) |
drawSegmentedBar(ctx, options): void
Options type: DrawSegmentedBarOptions.
Discrete segmented bar — health, ammo, shield, fuel, stamina, mana, battery, damage. Computes round(value/max * segments) filled segments.
Two colouring strategies, mutually exclusive:
- Single colour (
color): every filled segment uses it. Classic Robocop health style. - Threshold gradient (
colors: [low, mid, high]): the widget picks one of three colours based onvalue/max(< 1/3→ low,< 2/3→ mid, else high). Classic oxygen / damage indicator.
// Robocop-style health (single colour)
drawSegmentedBar(ctx, {
x: 0, y: 0, segments: 10, value: 7, max: 10,
color: C.B_GREEN, paper: C.BLACK,
})
// Oxygen with threshold gradient (red → yellow → green)
drawSegmentedBar(ctx, {
x: 0, y: 0, segments: 10, value: 8, max: 10,
colors: [C.B_RED, C.B_YELLOW, C.B_GREEN],
paper: C.BLACK,
})
// Vertical bar (e.g. ammo column on the side of the HUD)
drawSegmentedBar(ctx, {
x: 0, y: 0, segments: 8, value: 5, max: 8,
orientation: 'vertical', color: C.B_GREEN,
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| x, y | number | — | Top-left corner |
| segments | number | — | Total segment count |
| value, max | number | — | Filled = round(value/max * segments) |
| segmentWidth | number | 8 (CELL) | Width of one segment |
| segmentHeight | number | 8 (CELL) | Height of one segment |
| gap | number | 1 | Pixels between adjacent segments |
| color | SpectrumColor | — | Single fill colour (mutually exclusive with colors) |
| colors | [low, mid, high] | — | Three-stop threshold gradient |
| paper | SpectrumColor | — | Background for empty segments |
| orientation | 'horizontal' \| 'vertical' | 'horizontal' | Layout direction |
drawTank(ctx, options): void
Options type: DrawTankOptions.
Fluid container — ballast tanks, fuel gauges, water reservoirs, lava levels, oil drums, chemical canisters. Liquid fills from the bottom up.
// Submarine ballast tank (pill, cyan fluid)
drawTank(ctx, {
x: 8, y: 16, width: 16, height: 48,
fillPct: 0.66, shape: 'pill',
liquidColor: C.B_CYAN,
containerColor: C.WHITE,
emptyColor: C.BLACK,
})
// Generic fuel gauge (rect, yellow fluid)
drawTank(ctx, {
x: 200, y: 8, width: 24, height: 32,
fillPct: 0.4, shape: 'rect',
liquidColor: C.B_YELLOW,
containerColor: C.WHITE,
emptyColor: C.BLACK,
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| x, y, width, height | number | — | Container bounding box |
| fillPct | number | — | Fill level 0..1, clamped |
| shape | 'pill' \| 'rect' | 'pill' | 'pill' = rounded caps, 'rect' = sharp corners |
| liquidColor | SpectrumColor | — | Fluid colour |
| containerColor | SpectrumColor | liquidColor | Outline colour |
| emptyColor | SpectrumColor \| 'transparent' | C.BLACK | Fill for the empty portion. Use 'transparent' to leave it un-painted (so the underlying frame shows through) |
drawDial(ctx, options): void
Options type: DrawDialOptions.
Circular analog gauge with movable needle — RPM, speedometer, fuel, temperature, volume knob. Decorations (face fill, rim outline, tick marks) are optional; the needle alone is the minimum visible output.
// Submarine motor RPM gauge (range 0–3000)
drawDial(ctx, {
cx: 128, cy: 100, radius: 24,
value: 1500, min: 0, max: 3000,
needleColor: C.B_RED,
rimColor: C.WHITE,
tickColor: C.WHITE,
ticks: 7,
})
// Bare minimum: just the needle
drawDial(ctx, {
cx: 50, cy: 50, radius: 10,
value: 75, needleColor: C.B_GREEN,
})| Option | Type | Default | Description |
|--------|------|---------|-------------|
| cx, cy, radius | number | — | Centre and radius |
| value | number | — | Mapped to needle angle |
| min | number | 0 | Minimum value |
| max | number | 100 | Maximum value |
| startAngle | number (rad) | 3π/4 | Needle angle at min (bottom-left default) |
| endAngle | number (rad) | 9π/4 | Needle angle at max (bottom-right, after sweeping CW through top) |
| needleColor | SpectrumColor | — | Needle colour |
| faceColor | SpectrumColor | — | Optional filled disc background |
| rimColor | SpectrumColor | — | Optional circle outline |
| tickColor | SpectrumColor | — | Optional tick mark colour (requires ticks) |
| ticks | number | 0 | Number of evenly-spaced tick marks |
Angles use canvas convention: 0 = right, π/2 = down, π = left, 3π/2 = up — angles increase clockwise because the canvas y-axis points down. Default sweep covers the typical 270° gauge arc through the top.
drawCompassText(ctx, options): void
Options type: DrawCompassTextOptions.
Text-based heading indicator in the classic 80s tactical-display style [W [NW] N [NE] E] — current direction in the centre, highlighted, with two neighbouring directions on each side. Heading rounds to the nearest 45° step.
drawCompassText(ctx, {
x: 0, y: 168,
heading: 0, // N
color: C.WHITE,
highlightColor: C.B_YELLOW,
paper: C.BLACK,
})
// heading=0 → centre is N. Five labels: W, NW, N, NE, E
// → `W [NW] N [NE] E` — centre "N" in bright yellow, ±1 in brackets,
// outer ±2 ("W", "E") rendered plain.| Option | Type | Default | Description |
|--------|------|---------|-------------|
| x, y | number | — | Top-left of the rendered string |
| heading | number (degrees) | — | 0/360 = N, 90 = E, 180 = S, 270 = W (wraps automatically) |
| color | SpectrumColor | — | Colour for non-current direction labels |
| highlightColor | SpectrumColor | color | Colour for current direction (centre label) |
| paper | SpectrumColor | — | Optional background behind labels |
| brackets | boolean | true | Wrap only the ±1 (adjacent) directions in […]. The centre label and the outer ±2 directions are never bracketed. |
Stateful widget — Progress Bar
The progress bar is a managed widget: after a drawProgressBar call, the bar is automatically re-rendered on subsequent frames by renderUI until visibilityLength milliseconds have elapsed. Calling drawProgressBar again resets the timer.
drawProgressBar(ctx, options): void
Draws the bar immediately and registers it for managed redraws.
// Appears for 1.5 s after each volume change
drawProgressBar(ctx, {
id: 'volume', x: 88, y: 88, width: 80,
value: getMasterVolume(),
ink: C.B_GREEN, paper: C.BLACK,
border: { style: 'solid' },
visibilityLength: 1500,
})
// Permanent HUD element (visibilityLength: 0)
drawProgressBar(ctx, {
id: 'health', x: 0, y: 184, width: 40,
value: lives, min: 0, max: 3,
ink: C.B_GREEN, paper: C.BLACK,
visibilityLength: 0,
})tickUI(dtMs): void
Advances all managed bar timers. Expired bars are removed. Call once per frame.
renderUI(ctx): void
Redraws all currently visible bars. Call every frame after the game world render.
resetUI(): void
Clears all managed widget state. Call alongside resetInput() on phase transitions.
// Typical game loop
renderFrame(ctx, state)
tickUI(dt)
renderUI(ctx)
// Phase transition
resetInput()
resetUI()
appPhase = 'intro'input.ts — Keyboard & Gamepad Input
Handles directional movement with configurable keyboard repeat, transparent
gamepad polling, and single-consume flags for action buttons. Call
initInput() once at startup, then tickMovement(dt) every frame.
Direction type
type Direction = 'up' | 'down' | 'left' | 'right'initInput(repeatDelay?, repeatInterval?): void
Attaches keydown/keyup listeners. Idempotent — safe to call multiple times; timing parameters are always updated but listeners are only registered once.
Default key bindings: arrows = movement, W A S D = also movement, F = flag action, P = pause, Ctrl+Shift+B = debug toggle.
Gamepad support is automatic. tickMovement() polls the first connected
gamepad via the browser Gamepad API: D-pad / left stick move, button 0 maps to
consumeFlag(), button 9 maps to consumePause(), button 3 maps to
consumeDebug(), and any button triggers consumeAnyKey().
initInput() // default: 150ms initial delay, 80ms repeat
initInput(200, 60) // custom timingtickMovement(dtMs): Direction | null
Returns the active movement direction for this frame, or null. Handles the
keyboard delay/repeat state machine and gamepad polling internally. Call
exactly once per frame.
const dir = tickMovement(dt)
if (dir === 'left') player.x -= speed * dt
if (dir === 'right') player.x += speed * dt
if (dir === 'up') player.y -= speed * dt
if (dir === 'down') player.y += speed * dtConsume flags
Each function returns true exactly once per key press, then resets to false. Designed for single-fire events — menus, flags, pause, etc.
| Function | Default key | Typical use |
|----------|-------------|-------------|
| consumeFlag() | F / gamepad button 0 | Flag / unflag a tile |
| consumePause() | P / gamepad button 9 | Pause / unpause |
| consumeDebug() | Ctrl+Shift+B / gamepad button 3 | Toggle debug overlay |
| consumeAnyKey() | Any key / any gamepad button | Dismiss overlays, start game |
if (consumeFlag()) toggleFlag(playerX, playerY)
if (consumePause()) appPhase = appPhase === 'paused' ? 'game' : 'paused'
if (consumeAnyKey()) appPhase = 'game' // dismiss title screenisHeld(key): boolean
Returns whether a key is currently held down. Argument is KeyboardEvent.key.
if (isHeld('ArrowUp') && isHeld('ArrowRight')) moveDiagonal()resetInput(): void
Clears all pending key state immediately — held keys, direction, all consume flags. Call on phase transitions to prevent stale inputs carrying over.
appPhase = 'gameover'
resetInput() // discard any queued keypresses from gameplaysprite.ts — Free-Roaming Sprites
Sprites are entities that move in continuous pixel space — not locked to the 8×8 tile grid. Use them for players, enemies, bullets, particles: anything with physics or sub-pixel movement. They integrate directly with collision.ts for tile-map wall resolution.
Sprite interface
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| x | number | 0 | Horizontal position in game pixels (float allowed) |
| y | number | 0 | Vertical position in game pixels |
| vx | number | 0 | Horizontal velocity in px/ms |
| vy | number | 0 | Vertical velocity in px/ms |
| bitmap | Uint8Array | — | 8-byte sprite bitmap |
| ink | SpectrumColor | — | Foreground color |
| paper | SpectrumColor \| null | null | Background color, or null for transparent |
| flipX | boolean | false | Render mirrored horizontally (cached — no per-frame allocation) |
| visible | boolean | true | When false, renderSprite skips this entity |
createSprite(bitmap, ink, paper?): Sprite
Creates a Sprite at (0, 0) with zero velocity. paper defaults to null (transparent).
const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
const BULLET_BM = new Uint8Array([0x00, 0x00, 0x18, 0x3C, 0x18, 0x00, 0x00, 0x00])
const player = createSprite(PLAYER_BM, C.B_CYAN) // transparent bg
const bullet = createSprite(BULLET_BM, C.B_YELLOW, C.BLACK) // opaque bg
player.x = 16; player.y = 80moveSprite(sprite, dt): void
Advances sprite.x by vx * dt and sprite.y by vy * dt. Call once per frame, before collision resolution.
applyGravity(sprite, gravity, dt): void
Adds gravity * dt to sprite.vy. Call once per frame, before moveSprite.
gravityin px/ms² — typical values:0.002–0.005(platformer),0.008(debris)
applyGravity(player, 0.003, dt)
moveSprite(player, dt)
// then resolveX / resolveY...renderSprite(ctx, sprite): void
Draws the sprite at (Math.round(x), Math.round(y)). Skips
