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.
Driving actions
Section titled “Driving actions”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.
Driving the pointer
Section titled “Driving the pointer”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.
Holds and taps
Section titled “Holds and taps”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);}Stating what the player did
Section titled “Stating what the player did”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.
Cues and the produced files
Section titled “Cues and the produced files”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.
Other engine events
Section titled “Other engine events”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");