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. Opt-in stereo panning. 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
- Stereo panning (opt-in, non-breaking) — per-channel pan on the AY (
pan(ch)plusmono/abc/acbpresets) and independent per-channel volume fades, plus apanargument on the beeper (beep/playPattern). Default is centred/mono, so existing audio is byte-identical — a modern "under glass" affordance (e.g. directional cues for accessibility) - 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, dither/shade tones, 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, built-in
+/-volume with an auto-hide HUD bar (one render-loop line) - 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.
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-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)
Documentation
| Guide | What's inside |
|---|---|
| Getting started | Project setup + a complete first game, start to finish. |
| Rendering | Canvas, sprites, bitmaps, attr/mono screens, cache, scrolling, lighting, palette, font. |
| Audio | Beeper vs AY, the AY-3-8912 emulator, note-name music. |
| Collision | AABB, rect-vs-tile, pixel-precise — when and how. |
| Save / load | Typed localStorage saves with versioning and migration. |
| API reference | Input, sprites, animation, camera, scenes, tilemap, UI, particles, RNG, i18n, presentation, debug. |
| Examples | Runnable snippets + the flagship games. |
| API stability | What's Stable vs Experimental, the deprecation policy, and the road to 1.0. |
Background: retrospective · debug / telemetry analysis.
Modules
Everything is re-exported from the package root — import { setupCanvas, createAY, /* ... */ } from 'zx-kit'.
Zero runtime dependencies, sideEffects: false, fully tree-shakeable.
| Module | Summary | Guide |
|---|---|---|
palette |
SCALE, CELL, the 15-colour C object, SpectrumColor type |
rendering |
font |
96-char ROM 8×8 bitmap font, getCharRow |
rendering |
renderer |
Canvas setup, sprites, text, bitmaps, attribute maps, scanlines, dither/shade, border flash | rendering |
cache |
Offscreen layer cache with dirty-flag invalidation | rendering |
attrscreen |
Opt-in authentic per-cell ink/paper colour clash | rendering |
monoscreen |
Opt-in monochrome playfield + colour HUD (clash-proof) | rendering |
tilescroll |
Pixel-smooth sub-tile tilemap scrolling | rendering |
lighting |
Dithered cave darkness, one blit per frame | rendering |
audio |
1-bit beeper: square-wave notes, patterns, stereo pan, volume + built-in auto-hide volume bar | audio |
ay |
AY-3-8912: 3-channel tone, LFSR noise, 16 envelopes, per-channel stereo pan + volume | audio |
music |
AY music by note name (A5, C#4) + looping |
audio |
collision |
AABB / rect-vs-tile / pixel-precise mask overlap | collision |
save |
Typed save/load: versioning, migration, slots, throttle | save |
input |
Keyboard + gamepad movement, key-repeat, action flags, built-in +/- volume keys |
api |
sprite |
Free-roaming sprites: position, velocity, gravity, flip | api |
animation |
Frame timer, position tween, blinker | api |
camera |
Viewport follow with lerp + deadzone, world clamp | api |
scene |
Stack-based scene manager with lifecycle hooks | api |
tilemap |
Scrollable maps, solid tiles, O(1) id-index, bg swap | api |
ui |
Boxes, frames, panel titles, progress bars, gauges | api |
particles |
Allocation-free particle pool for pixel effects | api |
rng |
Seeded mulberry32 PRNG (int/range/float/pick/shuffle/fork) | api |
i18n |
Type-safe runtime locale selection | api |
presentation |
Title/loading helpers: blink, tape stripes, menus | api |
debug |
Frame-timing monitor + FPS/CPU/custom overlay | api |
Architecture
Module structure
zx-kit/
├── package.json # exports: { ".": "./dist/index.js" }, sideEffects: false
├── tsconfig.json # strict, emits to dist/
├── README.md
├── src/
│ ├── index.ts # barrel — re-exports everything
│ ├── palette.ts # SCALE, CELL, C, SpectrumColor
│ ├── font.ts # FONT, getCharRow
│ ├── renderer.ts # canvas setup, 8×8 sprites, arbitrary-size Bitmap,
│ │ # AttrMap colour attributes, text, scanlines, border flash
│ ├── cache.ts # createLayerCache, invalidateLayer, refreshLayer (offscreen cache)
│ ├── attrscreen.ts # createAttrScreen, stampMono, flushAttrScreen (authentic clash)
│ ├── monoscreen.ts # createMonoScreen, drawMonoBitmap, flushMonoScreen (anti-clash)
│ ├── lighting.ts # createDarknessLayer, renderDarkness (dithered darkness)
│ ├── audio.ts # beeper: initAudio, resumeAudio, beep (stereo pan), playPattern,
│ │ # getAudioContext, getMasterGain, getMasterVolume, setMasterVolume,
│ │ # increaseVolume, decreaseVolume, Note,
│ │ # setVolumeBarStyle, drawVolumeBar
│ ├── ay.ts # AY-3-8912: createAY, playAY, AYChannel, AYNote, AYChip,
│ │ # AYHandle, AYStereoMode (pan / setStereoMode / volume / fade),
│ │ # AY_VOL, AY_CLOCK, AY_ENVELOPE_SHAPES
│ ├── music.ts # noteToFreq, seq, playAYLoop (note-name AY music)
│ ├── input.ts # initInput, tickMovement, consumeFlag,
│ │ # consumePause, consumeDebug, consumeAnyKey,
│ │ # isHeld, resetInput, setVolumeKeys, Direction
│ ├── ui.ts # drawBox, drawFrame, drawPanelTitle, progress bars,
│ │ # gauges (drawDial, drawTank, drawSegmentedBar)
│ ├── tilemap.ts # createTileMap, Tile, Viewport, TileMap
│ ├── tilescroll.ts # drawTileMapAt, tileMapWorldSize (sub-pixel scroll)
│ ├── sprite.ts # createSprite, moveSprite, applyGravity,
│ │ # renderSprite, Sprite
│ ├── collision.ts # AABB, rect-vs-tile, pixel-precise masks
│ ├── particles.ts # createParticleSystem, emitParticles,
│ │ # tickParticles, renderParticles, clearParticles
│ ├── rng.ts # createRng, hashSeed (seeded mulberry32)
│ ├── animation.ts # frame timers, tweens, blinkers
│ ├── camera.ts # scrolling viewport, lerp, deadzone, bounds
│ ├── scene.ts # stack-based scene manager
│ ├── save.ts # typed localStorage save/load with migrations
│ ├── presentation.ts # blinkVisible, drawBlinkingText, drawTapeStripes, drawMenuOptions
│ ├── debug.ts # createDebugMonitor, beginFrame/endFrame, drawDebugOverlay
│ └── i18n.ts # pickLocale runtime locale selection
└── dist/ # compiled output (npm run build)
├── index.js
├── index.d.ts
└── ...
Design decisions
No runtime dependencies. Every module uses only Web platform APIs — CanvasRenderingContext2D, AudioContext, KeyboardEvent. There is nothing to install, no transitive vulnerabilities, no version drift from third-party packages.
Singleton state. audio.ts, ay.ts, and input.ts hold module-level state. This is intentional: a game has one audio context, one input handler. It is not suitable for multiple independent game instances on the same page.
Compiled distribution. The package ships compiled JS + .d.ts to dist/. Any bundler (Vite, webpack, esbuild, Rollup) consumes it without aliases or configuration.
sideEffects: false. All module-level initialisation is lazy — no DOM access, no event listeners, no network calls at import time. Bundlers can tree-shake any module whose exports are not used. Import only playAY and createAY and the beeper, input, and UI modules are completely excluded from your production bundle.
Spectrum aesthetic constants. The palette values, cell size (CELL = 8), and font bytes are fixed constants, not configuration — they define zx-kit's visual identity. The SpectrumColor type enforces the palette at the TypeScript level: you cannot accidentally pass an arbitrary hex string where a Spectrum color is expected. This is aesthetic discipline, not hardware emulation — zx-kit is a Speccy-flavoured fantasy toolkit, not a ZX Spectrum clone.
AY clock accuracy. AY_CLOCK = 1_773_400 Hz and AY_VOL[] are measured values from the real AY-3-8912 chip. The LFSR noise buffer uses the correct 17-bit polynomial (bit = (lfsr ^ (lfsr >> 2)) & 1). The logarithmic amplitude table uses the real chip's ≈ √2 step factor (3 dB per level).
License
MIT — see LICENSE.
zx-kit is extracted from Minefield, a ZX Spectrum-style minesweeper game.