Skip to content

Input and Audio

Input reaches the engine through the event target the harness owns, and audio leaves it through the event broadcaster. Both are engine surfaces, so a check drives a build and reads it back without the build exposing anything of its own.

The engine attaches its keydown and keyup listeners to the event target SurfaceMetrics.events() returns, reading code and repeat off each event. Dispatching a keyboard-shaped event at that target drives an action exactly as a player’s key does.

import { KEYS } from "./constants";
import type { Harness } from "./harness";
function keyEvent(type: "keydown" | "keyup", code: string): Event {
return Object.assign(new Event(type), { code, repeat: false });
}
export function hold(h: Harness, action: string): void {
for (const code of KEYS[action]) {
h.keys.dispatchEvent(keyEvent("keydown", code));
}
}
export function release(h: Harness, action: string): void {
for (const code of KEYS[action]) {
h.keys.dispatchEvent(keyEvent("keyup", code));
}
}

An event whose repeat flag is set arms nothing, so the helpers state it as false and a check drives a sustained action by leaving the key down rather than by repeating the press.

The engine attaches its pointer listeners to the same target, reading clientX, clientY, and isPrimary off each event. Over a surface with no origin, a dispatched event’s client position is read as CSS pixels from the canvas’s top-left corner, and a suite that pins the surface to the stage’s own size at a ratio of 1 dispatches logical coordinates directly.

function pointerEvent(
type: "pointerdown" | "pointermove" | "pointerup",
x: number,
y: number,
): Event {
return Object.assign(new Event(type), {
clientX: x,
clientY: y,
isPrimary: true,
});
}
export async function drag(h: Harness, path: Point[]): Promise<void> {
const [first, ...rest] = path;
h.keys.dispatchEvent(pointerEvent("pointerdown", first.x, first.y));
for (const point of rest) {
h.keys.dispatchEvent(pointerEvent("pointermove", point.x, point.y));
}
const last = path[path.length - 1];
h.keys.dispatchEvent(pointerEvent("pointerup", last.x, last.y));
await h.engine.advance(1);
}

Every event dispatched before the frame advances lands in that frame’s sample list in order, so a sweep across several targets is delivered as the positions it visited. A check that needs the press and the release seen on separate frames advances between the dispatches instead.

A key stays down until a keyup arrives, so a hold is a press, some frames, and a release. A helper that holds an action across a number of frames keeps the three steps in one place.

export async function holdFor(
h: Harness,
action: string,
frames: number,
): Promise<void> {
hold(h, action);
await h.engine.advance(frames);
release(h, action);
}
const paddle = world.byTag(TAGS.paddleP1)[0];
const before = paddle.transform.y;
await holdFor(h, "p1-up", 36);
expect(paddle.transform.y).toBeLessThan(before);

An edge is armed when an action’s value goes from zero to non-zero, and the engine closes the input frame after the frame renders, discarding every edge left unconsumed. A tap is therefore a press, exactly one frame, and a release.

export async function tap(h: Harness, action: string): Promise<void> {
await holdFor(h, action, 1);
}

A check drives by action name, and constants.ts resolves the name to the codes the case fixed for it. The names and the codes are the case’s, so a check states what the player did and the build’s binding is what answers for it. A build that bound an action to a different key is caught by the check that expected the action to respond.

The same holds on the reading side. value(name) reports 0 or 1 for a digital action and the magnitude as given for an analog one, and pressed(name) reports the edge, so a claim about a sustained push and a claim about a single press are separate claims stated in the same vocabulary.

Edges and the controllers that consume them

Section titled “Edges and the controllers that consume them”

Input reaches the simulation through PlayerController.input alone. Each player controller consumes edges independently: an edge is pressed exactly once for each controller that asks, so two controllers bound to one action each see the press, and the call consumes that controller’s copy.

A suite must therefore leave the build’s controllers alone when it wants the build to observe a press. Reading world.players()[0].input.pressed("confirm") consumes the copy the build’s own controller was going to read, and the build then behaves as though the key was never struck. A check that wants to read an action directly adds a controller of its own through world.mode.addPlayer and reads that one.

const observer = world.mode.addPlayer({ name: "observer", pawn: null });
hold(h, "pause");
await engine.advance(1);
expect(observer.input.pressed("pause")).toBe(true);
expect(world.paused).toBe(true);
release(h, "pause");

A press held across several frames arms one edge, so a check that expects two distinct presses releases between them.

The engine broadcasts cue:played for every cue a game plays. engine.events.on returns the function that removes the handler, so a check collects the cues of one window by subscribing before the act and unsubscribing after it.

import { CUES } from "../constants";
const cues: string[] = [];
const off = engine.events.on("cue:played", ({ cue }) => cues.push(cue));
engine.debug.setBallPosition(P1_X + BALL_R, paddle.transform.y);
engine.debug.setBallVelocity(-600, 0);
await engine.advance(10);
off();
expect(cues).toContain(CUES.paddleHit);

Because the event names the cue, a build that fires its scoring blip on every wall bounce fails rather than passing on a count. The payload also carries the simulated time the cue played at and the gain it played at, which places the cue in the run and distinguishes a muted play from an audible one: a play on a muted bus reports gain: 0, and a play on an unmuted bus reports the spec’s gain for a synthesized cue and 1 for a file-backed one.

A cue name carries one source, and playing a cue that was never declared throws, naming the cue. The engine reports every play regardless of the unlock state, so a cue check needs no gesture. Unlocking affects audibility, which a suite running in process has nothing to observe.

A loop is checked the same way. cue:looped is broadcast once when a cue starts looping and cue:stopped once when it ends, so a check that a thruster hum starts with the key and ends with its release subscribes to both and asserts the order, or reads the bus directly through world.audio.looping.

cue:played names the cue, and which produced file the cue was bound to is a fact the event leaves out. The engine decodes a produced sound through an AudioContext and sounds it through a buffer source of the same context, so a harness that stands a headless AudioContext up on globalThis reads both. Its decodeAudioData receives the bytes the served file returned, so the buffer it answers with is keyed to the path those bytes came from, and a buffer source’s start names the buffer it plays. The bus announces a cue before it starts the source, synchronously, so the source that starts next is that cue’s, and the cue is attributed to its file.

class HeadlessAudioContext {
readonly destination = {};
readonly state = "running";
currentTime = 0;
resume = () => Promise.resolve();
decodeAudioData(bytes: ArrayBuffer) {
const file = servedPaths.get(digest(new Uint8Array(bytes))) ?? "";
return Promise.resolve(new FakeBuffer(file));
}
createBufferSource = () => new FakeSource("file");
createOscillator = () => new FakeSource("synth");
createGain = () => ({ gain: fakeParam(1), connect() {}, disconnect() {} });
}
globalThis.AudioContext = HeadlessAudioContext;

A loop’s source has loop set and runs until stop, so the sources started and not stopped are the loops sounding, each with its cue and its file. That is the reading world.audio.looping gives, with the file beside it, and a muted play starts no source at all.

An engine holds one context for its loader and its bus, built by whichever asks first and kept: the loader while initialize decodes the produced sounds, or the bus at the unlocking gesture when the game loads none. A harness that runs several engines in one process binds a context to the harness that was current when it was built, and holds that binding across both engine.initialize and the gesture.

The engine unlocks on the first keydown or pointerdown at the surface’s event target, in the capture phase. A harness arms the bus by dispatching a keydown for a code the case binds to nothing, so arming changes no game state. The gesture is what lets a played cue start a source, so a check that reads the file behind a cue arms the bus first; the events need no gesture.

asset:loaded, asset:failed, and audio:unlocked are subscribed to the same way. Asset events are most useful around engine.initialize, where a subscription taken before the call observes the instance’s own loading and the start level’s load together.

const loaded: string[] = [];
engine.events.on("asset:loaded", ({ path }) => loaded.push(path));
await engine.initialize();
expect(loaded).toContain("sprites/paddles.png");