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

@wasm-gaming/engine-specs

v0.4.0

Published

The wasm-gaming engine contract: the single shared JS API every WASM game/emulator engine and the host app conform to. TypeScript types, no runtime dependencies.

Readme

@wasm-gaming/engine-specs

The engine contract for the wasm-gaming ecosystem: one shared JS API that every WASM game/emulator engine implements and every host drives. Define it once here, depend on it everywhere, and engines can't drift.

The types, plus the contract implemented once so twelve engines inherit it instead of re-deriving it — src/engine-specs.ts is the whole package. No runtime dependencies, because twelve engines depend on this.

The shape

An engine exports a class. Its identity is static, so a launcher can list engines without constructing one:

import type { EngineSDKConstructor, EnginePayloads } from '@wasm-gaming/engine-specs';

interface Rsdkv4Payloads extends EnginePayloads {
  config: { region: 'us' | 'eu' | 'jp' };
  options: { player: 'sonic' | 'tails'; slot: number };
  assets: { pack: Uint8Array };
  storage: { namespace: string };
}

// The one line that belongs in an engine's CI: does the class satisfy the contract?
const _contract: EngineSDKConstructor<Rsdkv4Payloads> = Rsdkv4SDK;

A host configures by chaining, and gets the live engine back:

const play = await new Rsdkv4SDK({ config: { region: 'eu' } })
  .assets({ pack })
  .mount(document.querySelector('#stage'))
  .start({ player: 'tails', slot: 0 });

await play.restart({ slot: 3 });
await play.destroy();

Read and write are different shapes

config, options, assets and storage are each one method with two overloads. Called with a patch it writes and keeps the chain; called with nothing it reads:

sdk.config({ region: 'eu' })        // writes, chains
await sdk.config()                  // reads
// → { region: { value: 'eu', default: 'us', enum: [...], description: '…', live: false } }

The getter returns a descriptor per key — enough to paint a settings screen without asking the engine anything, and without the host knowing a single key name. live says whether a property can be changed on a running engine or needs a restart(). The descriptor shape is never accepted on write: that asymmetry is what keeps a write unambiguous the day a value is itself an object.

Listening

on and off are on the same fluent surface, so they chain wherever the setters do — and registration is synchronous on every carrier, including the promise start() returns. The chain is what the return value continues, never what the subscription waits for; otherwise start().on('error', …) would bind after the session opened and miss an error thrown while it was opening.

A listener receives an event, not the payload: what it carries is detail, as CustomEvent does it. The envelope is what lets an event grow a field without every listener signature changing — and what survives postMessage intact when the engine lives in a worker, which is where this ecosystem is going.

const play = await new Rsdkv4SDK({ on: { error: report } })   // in the constructor
  .on('start', (event) => console.log('session', event.detail))
  .assets({ pack })
  .start();

It is deliberately not a DOM event: two members, frozen, no bubbling, no preventDefault(), no target. Half-implementing a familiar interface is worse than offering a different one.

The factory and the engine it opens are one channel, so subscribing before there is an engine is the normal way to do it. Every engine emits start, error, exit, pause and resume; an engine that has more of its own declares them in its payload bundle and they type-check alongside the core set:

interface Rsdkv4Payloads extends EnginePayloads {
  // …the four payloads
  events: { levelLoaded: { zone: string } };   // optional; omit it for the core set alone
}

error is emitted and the promise rejects — both, always — so a host that does not await is not left blind, and one that does is not forced to subscribe.

Writing an engine

None of the above has to be implemented by hand. EnginePlayBase and EngineSDKBase carry the state, the descriptors, the cumulative merge, the chainable promise and the channel; an engine declares its properties as data and fills in what only it knows.

import { EnginePlayBase, EngineSDKBase } from '@wasm-gaming/engine-specs';

const spec = {
  config: { region: { default: 'us', enum: ['us', 'eu', 'jp'], live: false } },
  options: { slot: { default: 0, live: true } },
  assets: { pack: { allowedTypes: ['.bin'], live: false } },
  storage: { namespace: { default: 'rsdkv4', live: false } },
};

class Rsdkv4Play extends EnginePlayBase {
  constructor(init, events) { super(spec, init, events); }
  async open(options) { /* start or reopen a session */ }
  async close() { /* stop and release */ }
  mounted(target) { /* the picture goes here */ }
}

export class Rsdkv4SDK extends EngineSDKBase {
  static id = 'rsdkv4';
  static version = '1.0.0';
  createPlay(init, events) { return new Rsdkv4Play(init, events); }
}

default is declared in the spec and nowhere else — the base seeds its starting state from it, so the initial value and the advertised default cannot drift apart. start, exit and error are emitted for you, including the rule that a failing hook both rejects the chain and emits error; this.emit(…) is for the engine's own events.

Adoption is optional: the contract is structural, so an engine with its own emitter and its own state satisfies EngineSDK without extending anything. EventEmitter and hydratePlay are exported for engines that want the pieces rather than the whole.

Menus: one usability scheme

Engines also ship their own screens (start / load game, pause, options) as an engine app, and all of them — and the launcher — follow one scheme, described in UX.md. The package carries its runtime, so no engine needs another dependency:

import { createInput, nextFocus, uiTokensCss } from '@wasm-gaming/engine-specs';

const input = createInput();                 // keyboard + gamepads → actions, with repeat
input.on((action) => {                       // 'up' | 'down' | … | 'accept' | 'back' | 'menu' | 'more'
  if (action === 'down') focus(nextFocus(current, buttons, 'down'));
});
input.suspend();                             // the game has the controls now

An app implements EngineApp — mount(target, host) — and receives game, storage, assets, the shared input and exit() from the host.

Closing the types

One type parameter carries all four payloads, so there is no order to get wrong:

type Sdk = EngineSDK<Rsdkv4Payloads>;

Leave it off and the payloads stay open (Record<string, unknown>), which compiles but gives up the checking that is the point.

Build

npm install
make build            # tsc → dist/ (cleans first)
make typecheck        # the contract
make typecheck-tests  # the compile-time conformance suite
make typecheck-demo   # the demo engine, held to the same contract
make test             # all of the above + the package integrity tests
make docs             # typedoc → site/
make site             # reference at /, demo at /demo/
make preview          # serve site/ on :8020 (PORT= to change it)

Conformance

Two engines implement the contract in this repo, and both are type-checked in CI: tests/engine.conformance.ts, the reference engine, and demo/demo.sdk.js, which drives the demo page. Between them they cover both branches of the event map — the reference engine declares one of its own, the demo omits events and gets the core set alone. The negative cases live in tests/types.test-d.ts, guarded by @ts-expect-error — which fails if a rejection ever stops happening.

The emitter is the one part with behaviour to run rather than compile, so it has tests that run: tests/event-emitter.test.mjs.

License

MIT.