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

keiko

v0.6.0

Published

Fast, deterministic tests for web games: play your real game in Node with no browser, with time, input and randomness under the test's control

Downloads

1,648

Readme

keiko

稽古 (keiko): practice. Fast, deterministic tests for web games.

keiko runs your real game in Node, with no browser and nothing drawn. Time, input and randomness only move when the test says so, so every run plays out the same way. A typical test takes tens of milliseconds.

// tests/movement.test.ts
import { expect, play, test } from "keiko";

test("the player reaches top speed within a quarter second", async () => {
  const game = await play();
  game.press("ArrowRight");
  expect(game.until((s) => s.player.vx >= 140)).toBeAtMost(15);
});
npx keiko

It works with any game that runs in a browser and loads through an index.html, whatever the engine: it drives games through the web platform (frames, timers, the clock, DOM events) rather than an engine API. debuff games get the deepest integration: npx debuff test, the debuff MCP server's run_tests tool, and their dev tools as the driver.

Engines it's tested with

Each has a small game in compat/ that checks boot, holding a key to move, a click at an exact position, seeded randomness and a determinism check. Maintainers run it with npm run compat.

| Engine | Version | Keyboard | Pointer | Deterministic | Notes | | --- | --- | --- | --- | --- | --- | | debuff | 0.3 | ✔ | ✔ | ✔ | Driven through its dev hooks | | Phaser | 4.2, 3.90 | ✔ | ✔ | ✔ | | | PixiJS | 8.22 | ✔ | ✔ | ✔ | | | KAPLAY | 3001.0 | ✔ | ✔ | ✔ | Needs keiko 0.4.2+ for click positions | | Excalibur | 0.32 | ✔ | ✔ | ✔ | | | three.js | r186 | ✔ | ✔ | ✔ | | | Hand-written canvas loop | | ✔ | ✔ | ✔ | |

Real games

keiko also plays open-source games it was never written for. Each needs only a small window.__KEIKO__ hook added, and each also has a test that uses no hook at all, only game.engine. To reproduce these rows, run npm run compat once, then node compat/real.mjs from the repo root. It clones each game at a pinned commit, applies the hook from compat/real/<game>/hook.patch and runs the tests next to it. It runs locally only, not in CI.

| Game | Engine | Tests | Deterministic | | --- | --- | --- | --- | | Runner | Phaser 3 | score, jump, game over (tests) | ✔ (same seed) | | Starshake | Phaser 3 | start, move, shoot (tests) | ✔ (same seed) | | Puzzling Potions | PixiJS 8 | Play opens a full board (tests) | ✔ (same seed) | | Bubbo Bubbo | PixiJS 8 | Play reaches the game (tests) | ✘ (see limits) |

"Same seed" means two runs with the same Math.random() seed match step for step (sameSeed: true). These games call Math.random() for spawns and boards, so a different seed plays a different game, as it should.

If your engine trips over the headless page, please open an issue with a minimal game.

This README is the complete reference. Using debuff? Its guide, debuff.jp/docs/testing, walks through keiko with debuff projects.

What makes it different

  • It plays your actual game. play() boots the scripts your index.html loads, through your Vite config, in a fresh headless page. No mocks of your game and no special build.
  • Time is the test's. requestAnimationFrame, setTimeout, setInterval, requestIdleCallback, performance.now() and Date (Date.now(), new Date()) run on a virtual clock that only moves when the test steps it, so engines with their own loop, and gameplay built on timers, play frame by frame the same way every run. Math.random() is seeded.
  • It asks game questions. How long until the player reaches top speed (until)? Does the player ever leave the screen (during)? How high is the jump (trace)? Does it still work at every gravity you might ship (test.sweep)? Is the game deterministic (test.deterministic)?
  • Failures show the game. Every failure reports the frame, time, scene and state when it broke, the line in your test, and the console output.
  • Bugs become tests. game.replay(file) plays a recorded scenario, so a bug found by hand or by a fuzzer becomes a millisecond regression test.

Setup

npm install -D keiko
npx keiko init

keiko init looks at your dependencies and writes tests/smoke.test.js (or .ts) for the engine it finds: the game boots and plays 5 seconds without an error. It never overwrites a file. It also prints a window.__KEIKO__ hook for your entry file, ready to fill in for when tests need your game's own state.

Put tests in tests/**/*.test.ts (or .js; src/**/*.test.ts works too) and run npx keiko. If you use TypeScript, add "tests" to include in tsconfig.json.

Phaser and PixiJS: no hook needed to start

For Phaser 3 and PixiJS 8 games, keiko reads game.engine from the engine itself:

  • scene: the topmost active scene (Phaser) or screen (PixiJS)
  • scenes: all of them
  • targets: what a player can press, with centres in page pixels. These are objects that are visible, taking input and on screen, with their type, text and name

That's enough to press Play and land on the game:

const game = await play({ ready: (g) => g.engine?.scene === "HomeScreen" });
await game.tapWhenReady((g) => g.engine?.targets.find((t) => t.text === "Start Game"));
await game.eventually(() => game.engine?.scene === "GameScreen");

tapWhenReady waits until the target exists and has stopped moving, so a button that fades or slides in gets pressed where it lands. Phaser 4's ES module build has no global, so it needs the hook below.

Let tests see your game

Without anything else, keiko can play your game: it steps the virtual clock and sends real keyboard and pointer events. To read the game's state, expose a snapshot as window.__KEIKO__ in development builds:

if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    state: () => ({ player: { x: player.x, y: player.y, vx: player.body.velocity.x }, score }),
    scene: () => currentScene.key,
  };
}

With Phaser, for example:

const game = new Phaser.Game(config);
if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    state: () => {
      const level = game.scene.getScene("level") as Level;
      return level.player ? { player: { x: level.player.x, y: level.player.y } } : undefined;
    },
    scene: () => game.scene.getScenes(true)[0]?.scene.key,
  };
}
const game = await play({ ready: (g) => g.scene === "level" && g.state !== undefined });

Adding keiko to a game you didn't write, where the scenes and buttons already exist? The hooking-up guide has copy-paste hooks for Phaser scenes and PixiJS screen managers, including how to expose button positions for tap().

Every hook is optional:

| Hook | Used by | | | --- | --- | --- | | state() | game.state, until, during, trace | A JSON-friendly snapshot of what tests assert on | | scene() | game.scene | The active scene or screen's name | | step(n) + stepSeconds | every step | For engines with their own fixed step. Default: one virtual frame per step | | press(code), release(code) | key input | Default: DOM keydown/keyup on the focused element, bubbling to window | | pointerDown(x, y), pointerMove, pointerUp | pointer input | Default: DOM pointer and mouse events on the first <canvas> | | params(), setParam(group, key, value) | game.param, game.set, params option, test.sweep | Tunable values: { player: { speed: 140 } } | | command(name, ...args) | game.command | Shortcuts for tests, like warping to a level |

For types, add import type {} from "keiko"; to the file that sets window.__KEIKO__ (it's erased from the build).

debuff games

Nothing to add. keiko detects debuff projects from their dependencies and drives them through devTools({ input }), which every debuff template installs. game.state is the active scene's inspect(), params and commands are debuff's, and steps are the game's own fixed steps. Run tests with npx debuff test or npx keiko (keiko loads debuff's Vite plugin from the project's @debuffjp/cli), or let an agent run them with the MCP server's run_tests tool.

API

play(options?)

Boots a fresh game and returns a Playtest. Calling it again replaces the previous game.

| Option | Default | | | --- | --- | --- | | page | "index.html" | Page to boot, relative to the project root | | params | | Params to set before the first step: { "player.speed": 200 } | | ready | once the scripts and their first loads finish (debuff: once a scene is active) | When the game counts as ready, e.g. (g) => g.scene === "level-1". keiko plays frames while it waits | | timeout | 10000 | Milliseconds of real time to wait for the scripts to load, then for ready. Without it, the first game in a run gets 60000 to load, since it also waits for Vite to pre-bundle dependencies | | seed | 1 | Seed for Math.random() | | fps | 60 | Frames per second of the virtual clock | | webgl | true (debuff: false) | Offer WebGL. false makes engines fall back to Canvas 2D | | viewport | { width: 960, height: 540 } | The window's size, e.g. { width: 390, height: 844 } for a portrait game |

play() resolves once the game's scripts have run, its first requests (assets, data) have finished, and ready passes. For games on the virtual clock it then plays one frame, as a browser paints before a player can act, so game.tick starts at 1. debuff games start at tick 0.

Type the state for autocompletion: play<{ player: { x: number } }>().

Playtest

Reading:

| | | | --- | --- | | game.state | The game's state snapshot | | game.scene, game.tick, game.time | Scene name, steps played, simulated seconds | | game.get("state.player.x") | Any path of everything the driver reports | | game.param("player.speed"), game.params() | Param values | | game.window | The game's page, for globals and the DOM | | game.stepSeconds | Seconds per step | | game.driver | How keiko drives this game (kind: "debuff", "keiko" or "clock") | | game.engine | What keiko reads from Phaser 3 or PixiJS 8 with no hook: scene, scenes, targets (see above) |

Playing (each returns the playtest, so calls chain):

| | | | --- | --- | | wait(duration = 1) | Play with the current input | | press(code) / release(code) | Hold or let go of a key (KeyboardEvent.code: "ArrowLeft", "KeyZ", "Space") | | hold(code, duration) | Press, wait, release | | tap(code) / tap(x, y) | A key or pointer press for one step | | pointerDown(x, y), pointerMove(x, y), pointerUp() | The pointer | | set("group.key", value) / set({ … }) | Set params | | command(name, ...args) | Run a game command | | replay(fileOrScenario) | Play a scenario's steps (see below) | | await settle() | Wait, in real time, for requests the game started (lazy-loaded assets) to finish. Game time doesn't move |

A duration is a number of steps (30) or time ("0.5s", "250ms"), rounded to whole steps.

Asking:

| | Returns | | | --- | --- | --- | | await run(duration) | the playtest | Like wait, but lets the game's async code (promise chains, tweens awaited in UI flows, loads) continue between frames, like a browser does | | await tapWhenReady(find, { within, steady }) | the playtest | Waits until find(game) returns a point ({ x, y }, or an engine target) that has stayed put for steady steps (default 10), then taps it | | await eventually(condition, { within }) | steps taken | Like until, letting async code and loads finish between frames. Use it after an action that loads a screen | | until(condition, { within }) | steps taken | Plays until condition(state) holds. Fails with the last state if it doesn't within within (default "10s") | | emitted(name) | number | How many times the game emitted name (debuff: game.emit("coin")), 0 until the first time. game.until(() => game.emitted("coin") > 0) | | during(duration, check, message?) | the playtest | Plays and checks an invariant after every step; fails at the step where it broke. check can return false or throw | | trace(pathOrFn, duration) | a Trace | Plays and records a value every step |

A Trace has start (the value before the first step), first, last, at(i), min(), max(), range(), stepOf(valueOrPredicate), every(), some(), changes() and timeAt(step), and is iterable:

game.press("Space");
const y = game.trace("player.y", "1s");
expect(y.start - y.min()).toBeAbove(40); // jumps at least 40 px (y grows downward)
expect(y.last).toBe(y.start);           // and lands where it took off

test, describe

test(name, fn, { timeout? });   // fn is usually async; default timeout 30 s of real time
test.only(name, fn);            // run only focused tests
test.skip(name, fn?);           // registered, not run
test.todo(name);                // a reminder in the report
describe(name, () => { … });    // groups nest; describe.only / describe.skip apply inside
beforeEach(fn);                 // before each test in this file or describe (outer hooks first)
afterEach(fn);                  // after each test, even failing ones (inner hooks first)

Shared setup:

describe("the hollow", () => {
  let game: Playtest<State>;
  beforeEach(async () => {
    game = await play<State>();
    game.command("warp", "hollow");
  });

  test("the door starts shut", () => {
    expect(game.state.doorOpen).toBe(false);
  });
});

Sweeps run the same test across params, one test per combination:

test.sweep("clears the first gap", { "player.jumpSpeed": [260, 290, 320], "player.gravity": [800, 900] }, (game) => {
  game.hold("ArrowRight", "0.4s").tap("Space");
  game.until((s) => s.player.x > 120, { within: "2s" });
});

Determinism checks play a script twice and compare the state after every step. A deterministic game is what makes replays, fuzz findings and recorded bugs play back the same way every time, so a failure in CI is one you can reproduce. The second run gets a different Math.random() seed and a different date, so logic that depends on unseeded randomness or the wall clock is caught:

test.deterministic("ten seconds of play are reproducible", (game) => {
  game.hold("ArrowRight", "2s").tap("Space").wait("8s");
});
// ✖ the two runs diverged at tick 37: state.enemies.0.x was 112 in the first run and 118 in the second.

Pass { sameSeed: true } when the game takes all its randomness from Math.random(), as engine helpers like Phaser.Math.Between do. keiko seeds it, so the game replays exactly from a seed, and the check then confirms nothing else (the clock, real-time timers) leaks in. The error says so when that's the likely fix.

expect(value)

toBe, toEqual, toBeAbove, toBeBelow, toBeAtLeast, toBeAtMost, toBeLessThan, toBeGreaterThan, toBeBetween, toBeCloseTo(target, tolerance = 0.001), toBeTruthy, toBeFalsy, toBeDefined, toBeUndefined, toContain, toHaveLength, toMatch, toThrow, and .not for each.

Errors

| Error | When | | --- | --- | | ExpectationError | An expect failed | | GameError | The game threw while being played. Has tick, scene and the original error as cause | | TimeoutError | until ran out of time | | InvariantError | during caught a broken invariant | | MissingHookError | A test used a hook the game doesn't expose |

Errors a game raises outside any test (say, an asset that fails to load after its test ended) are listed at the end of the run and fail it.

Replaying scenarios

game.replay() takes a JSON file (or object) of steps: wait, press, release, tap, pointerDown, pointerMove, pointerUp, set, command, expect and until (within in steps, default 600). It's the debuff scenario format, so debuff's overlay replays and fuzz findings play directly; any tool can write it. capture and contrast steps are skipped, and flashes only plays its frames, since nothing is drawn: those are checked by debuff scenario. Plugin steps (names with a :) are refused.

The CLI

npx keiko init                # a first test for your game, for the engine it uses
npx keiko                     # every test
npx keiko tests/jump.test.ts  # some files
npx keiko tests/levels        # every test in a folder
npx keiko -t "pause"          # tests whose full name contains "pause" (case-insensitive substring, not a regex)
npx keiko --json              # a report for tools and agents
npx keiko --root games/demo   # a project in another folder
npx keiko --no-debuff         # drive a debuff game through __KEIKO__ instead (--debuff forces the opposite)

Exits with 1 when a test fails. For tools, the same runner is runTests() from keiko/runner.

What runs, and what doesn't

The game runs in a happy-dom page with your project's Vite config.

  • Rendering: Canvas 2D and WebGL 1/2 contexts accept every call and draw nothing. Shaders compile, framebuffers are complete and pixel reads return zeros, so engines boot whichever renderer they prefer.
  • Sound: Web Audio plays nothing; decoding succeeds instantly.
  • Assets: images and other files load from the Vite server. Images are real <img> elements that report their size (PNG, GIF, JPEG) without decoding pixels. Fonts count as loaded.
  • Layout: the canvas's box is its CSS size (style.width/height, else its pixel size) at the page's origin, so engines that map pointer positions through it get the coordinates you passed.
  • Workers: Web Workers run inline on the page's thread, including from blob: URLs, with fetch and createImageBitmap.
  • Shaders: programs report the attributes and uniforms their GLSL declares, so engines that enumerate them bind without warnings.
  • Time: everything above runs on the virtual clock. Node's own timers (inside fetch, for example) keep real time. To wait in real time inside a test, use await sleep(ms) from keiko, not setTimeout, which is the game's virtual one while a game runs.

So keiko covers simulation, input, timing, params, scenes and anything your state snapshot exposes. It doesn't cover what's drawn or heard: for that, use screenshot tests in a real browser (debuff's scenarios). One game runs at a time.

Known limits

keiko assumes the common case and stays fast rather than modelling every browser timing. Some things it doesn't do:

  • Timing races between real loads and frames. keiko waits for loads between frames when you use run() or eventually(), but work that finishes on its own schedule (a worker reply, a chain of loads that starts more loads) can land on a different frame from one run to the next. A game whose logic depends on that ordering, like Bubbo Bubbo switching screens when its assets arrive, won't be frame-for-frame deterministic. Assert on outcomes (eventually(s => s.screen === "game")) rather than exact ticks for those flows.
  • Pixels and sound. Nothing is drawn or heard; use a real browser for that.
  • Native layout. There's no CSS layout engine, so DOM-based UI positions are only what the game sets explicitly.
  • WebGPU, WebAssembly threads, SharedArrayBuffer workers aren't provided.

The design is recorded in ADR 0009.

License

MIT