Skip to content

Engine

createEngine is the only function the root entry point exposes. It builds the engine over a canvas and wires the subsystems together. The game is bound here, so an engine drives exactly one game for its lifetime.

function createEngine<S, D = unknown>(
options: EngineOptions<S, D>,
): Engine<S, D>;

S is the game’s state type and D is its debug surface. Both are inferred from the game the options carry.

Construction performs no loading and runs no game code. An engine therefore exists in a state where its clock can be replaced and its events can be subscribed to before anything the game does is observable, which is what lets a caller watch the game’s own initialization.

interface EngineOptions<S, D = unknown> {
canvas: HTMLCanvasElement;
width: number;
height: number;
game: Game<S, D>;
background?: string;
layout?: string;
clock?: Clock;
surface?: SurfaceMetrics;
assetRoot?: string;
}
FieldDefaultMeaning
canvas—The canvas the engine sizes, clears, and renders through.
width—The logical design width the game draws in. Finite and positive.
height—The logical design height the game draws in. Finite and positive.
game—The game this engine drives.
background—A CSS color cleared to before every frame. Absent, the frame is cleared to transparency. The letterbox bars hold it alone: the context is clipped to the logical field for update and render.
layout—A touch layout from the catalogue, whose vocabulary the game then registers.
clocknew WallClock()The clock supplying each frame’s delta.
surfaceRead from the canvasWhere the engine reads its element size and device pixel ratio.
assetRoot"assets/"The root every asset path resolves under.
interface SurfaceMetrics {
cssWidth(): number;
cssHeight(): number;
dpr(): number;
events(): EventTarget;
origin?(): { x: number; y: number };
claimGestures?(): () => void;
capturePointer?(pointerId: number): void;
releasePointerCapture?(pointerId: number): void;
}

The engine reads the canvas’s laid-out size and device pixel ratio through this seam every frame, and attaches its key and pointer listeners to the event target it returns. Supplied, it replaces every measurement the engine would otherwise take from the DOM, which is what lets the engine run over a canvas with no document behind it.

origin() is the canvas’s top-left corner in the client coordinate space pointer events report their positions in, and it is what the engine subtracts before mapping a pointer position onto the stage. Absent, the origin reads (0, 0), so a dispatched pointer event’s client position is read as CSS pixels from the canvas’s corner.

claimGestures() takes the browser’s own pointer gestures on the surface, and returns the function that gives them back. Those gestures are panning, pinch-zoom, double-tap zoom, text selection, the wheel’s page scroll, and the context menu, and while they are claimed a drag, a wheel, and a press of the secondary button all reach the game instead. The engine calls it once as the pointer attaches and calls the returned function when the pointer detaches.

capturePointer(pointerId) routes every later event for that pointer to the surface until releasePointerCapture(pointerId), so a drag that leaves the canvas keeps delivering moves and its release is seen. The engine captures each pointer as it comes into contact and releases it as it leaves.

The three are optional, and a surface with no element behind it supplies none of them. The default surface implements all three against the canvas element.

Absent entirely, the engine reads clientWidth, clientHeight, the owning window’s devicePixelRatio, and the canvas’s bounding rectangle for the origin, listens on the canvas’s owning document, and claims and captures pointers on the canvas element.

interface Engine<S, D = unknown> {
readonly events: EngineEvents;
readonly state: DeepReadonly<S>;
readonly debug: D;
initialize(): Promise<DeepReadonly<S>>;
apply(transition: Transition<S>): DeepReadonly<S>;
run(options?: RunOptions): Promise<void>;
advance(frames: number): Promise<void>;
setClock(clock: Clock): void;
frame(): FrameInfo;
viewport(): Viewport;
diagnostics(): readonly DiagnosticReading[];
recording(): boolean;
startRecording(): void;
stopRecording(): Recording;
destroy(): void;
}
interface RunOptions {
signal?: AbortSignal;
}
MemberEffect
eventsSubscribe to engine events. Available from construction.
stateThe current state, as a read-only view: the value the most recent transition left.
debugThe debug surface the game returned beside its state.
initializeRun the game’s initialize and resolve to the state it produced.
applyReplace the state with what a Transition<S> returns from the current one, and return the new state.
runDrive the game off the host’s frame callback until the supplied signal aborts.
advanceTick the clock frames times, running a frame for each tick the clock accepts.
setClockReplace the clock. The next frame takes its delta from the new one.
frameThe frame counter, the accumulated simulated time, and the most recent delta.
viewportThe current logical-to-device fit, as a snapshot the caller owns.
diagnosticsEvery registered diagnostic source and what it reports now, in registration order.
recordingWhether draw-command recording is currently capturing.
startRecordingArm the recorder. Capture begins at the next frame.
stopRecordingDisarm the recorder and return everything captured since startRecording.
destroyHalt the loop and drop every listener.

initialize runs the game’s own initialize and awaits its result, then holds the state it produced. It resolves to that state, so a caller receives a value that is complete rather than one it has to test before using.

Calling it a second time resolves to the state already built, so a caller that cannot easily tell whether initialization has happened may ask again.

The engine runs no frame before this resolves. update and render therefore never observe a partially built state, which is what allows the game’s state type to declare every field as present.

The current state, as DeepReadonly<S>. It is the value rather than a live reference: each frame replaces the state with what update returned, and each apply replaces it with what the transition returned, so a read hands back the state the most recent transition left and holds nothing a later frame writes to. A caller that wants the state after further frames reads engine.state again.

Reading it before initialize resolves throws, naming the ordering. That keeps a contract violation loud at the point of the mistake.

const posed = engine.apply((state) => ({
...state,
ball: { ...state.ball, vx: 0 },
}));

apply hands the current state to transition, holds the state it returns, and returns that state as DeepReadonly<S>. It is how a caller poses a game between frames: the next frame’s update receives the state the transition left, so the collision, the serve, or the spawn a scenario is about is still computed by the game’s own update. A debug surface’s poses are transitions, and a caller drives one as engine.apply((state) => engine.debug.setBallVelocity(state, 240, 0)).

Calling it before initialize resolves throws, naming the ordering, exactly as reading state does. A transition that returns undefined is refused with an error naming must return the next state, and the engine keeps the state it had.

The second element of the pair the game’s initialize returned, unchanged. The engine holds it and reads no member of it, so its shape is whatever the game declared as D.

Reading it before initialize resolves throws, naming the ordering and the [state, debug] pair, exactly as state does. A game with no surface returns null there, and engine.debug hands that null back.

run drives frames off the host’s frame callback, and the returned promise resolves once the loop halts. A loop halts when the supplied signal aborts or when the engine is destroyed.

const controller = new AbortController();
await engine.run({ signal: controller.signal });

A signal is how one teardown path halts the loop alongside everything else it already cancels. A game that ends itself creates a controller in its own initialize, stores it in the state, and aborts it from update, so ending the game needs nothing from the engine beyond what the game already holds.

Omitting the signal runs until the engine is destroyed. Calling run while the loop is already running resolves against the same halt rather than starting a second loop.

advance ticks the clock frames times and resolves. Ticks run back to back with no host frame callback between them, so the elapsed real time has no effect on the result and there is nothing to wait for or poll.

A tick the clock declines runs no frame, so a clock supplying its own deltas turns frames ticks into exactly that many frames.

frames must be a whole, non-negative number. advance(0) runs nothing.

A clock that reads nowMs reports near-zero deltas here, because no real time passes between frames. Pair advance with a clock that supplies its own deltas.

The clock is replaced in place, and the frame counter and accumulated time carry over. A clock installed mid-run takes effect on the next frame.

ConditionResult
A width or height that is not finite and positiveError naming the size
A canvas that yields no 2D contextError
A layout outside the catalogueError naming every valid layout
The game’s initialize throws or rejectsinitialize rejects with the cause
state, apply, run, or advance reached before initialize resolvesError naming the ordering
A transition handed to apply returns undefinedError naming must return the next state; the state is unchanged
debug read before initialize resolvesError naming the ordering and the [state, debug] pair
The game’s initialize returns anything but a two-element arrayinitialize rejects with an Error naming the [state, debug] pair
advance with a count that is not a whole, non-negative numberRangeError naming the value
startRecording while already recording, or stopRecording while notError naming the unbalanced call

Each construction failure otherwise presents as a build that runs and draws nothing, which is the most expensive kind to trace, so each is refused where it happens.

Returns a Viewport snapshot: the logical design size, the device pixels per logical unit, and the two letterbox offsets. The returned object is a copy, so holding one does not observe later frames.

Halts the loop and detaches every listener. Idempotent, because teardown races.

Destroying resolves any promise run returned. Aborting a run’s signal halts the loop and leaves the engine usable, so the two are separate acts.

createEngine is the root entry point’s only function. EngineOptions, SurfaceMetrics, Engine, RunOptions, Transition, and DeepReadonly are exported as types from @clockwyrks/simple-2d.