npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

npm license


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 SpectrumColor type
  • 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-pixel fillRects into a single drawImage
  • 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, flipX caching, 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 localStorage with 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-shakeablesideEffects: false, so unused modules are dropped from your production bundle
  • TypeScript-first — strict mode, full .d.ts declarations, no any

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-kit

Then 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-kit

Use npm install ../zx-kit --prefer-online if 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 -y

npm 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 together

5c — 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 timer

5f — The game loop

Every frame the browser calls loop. Inside, we:

  1. Calculate dt — how many milliseconds passed since last frame
  2. Move the player based on held keys
  3. Keep the player inside the screen
  4. Pick the right animation frame
  5. Play a footstep sound periodically while moving
  6. 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 frame

Step 6 — Run the game

npm run dev

Open 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's OscillatorNode. 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's OscillatorNode is 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, so setMasterVolume() 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 / Melodik

The 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)
  • AudioParam automation 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, default 256
  • height — game pixels tall, default 192
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 screen

mirrorSprite(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 bg

drawText(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 bg

drawTextCentered(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.

  • resetColor defaults to C.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 border

drawScanlines(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 scanlines

curveDisplay(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 warp

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

  • X or # = 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:

  • inkOnly suppresses 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, and drawBitmapAttrs(..., inkOnly) matches a paper-less AttrMap, pixel-for-pixel;
  • exception path: drawBitmapAttrs still throws on an AttrMap/Bitmap dimension mismatch with inkOnly set.

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.ts for 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 GainNodesetMasterVolume() 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)    // full

increaseVolume() / 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 later

ui.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 on value/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 timing

tickMovement(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 * dt

Consume 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 screen

isHeld(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 gameplay

sprite.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 = 80

moveSprite(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.

  • gravity in px/ms² — typical values: 0.0020.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