Skip to content

Game

A game is three functions and two types: its state, and the debug surface returned beside it. The game is bound to the engine when the engine is created; initialize runs once when the engine is initialized, and update and render run once each per frame after that. Each function receives only the part of the engine it is allowed to use.

import type { DeepReadonly } from "ts-essentials";
interface Game<S, D = unknown> {
initialize(api: InitApi<S>): [S, D] | Promise<[S, D]>;
update(state: DeepReadonly<S>, api: UpdateApi, dt: number): S;
render(state: DeepReadonly<S>, api: RenderApi): void;
}
MemberCalledReceivesReturns
initializeOnce, from engine.initializeInitApi<S>[state, debug], or a promise of it
updateOnce per frame, firstThe current state as DeepReadonly<S>, UpdateApi, and the frame’s delta in secondsThe next state
renderOnce per frame, after updateThe state update returned, as DeepReadonly<S>, and RenderApiNothing

S is the game’s own state, the first element of the pair initialize returns. The engine holds it by value: each frame is a transition over it, where update is handed the current state as a read-only view and returns the next state, and render is handed that next state as the same view. The value update returns is what render draws, what engine.state reads, and what the next update receives.

DeepReadonly<S> is the ts-essentials type of that name, re-exported from @clockwyrks/simple-2d. Every reader of the state is handed it, so render cannot change the state and nothing but a transition advances it, and the compiler is what says so. A game writes update as a pure function that builds the next state from the current one, with spreads over the parts that changed:

update(state, api, dt) {
return { ...state, ball: { ...state.ball, x: state.ball.x + state.ball.vx * dt } };
}

update returns the next state on every path. A return of undefined is refused with an error naming must return the next state, and the engine keeps the state it had.

D is the game’s debug surface, the second element of that pair and the value the engine returns unchanged from engine.debug. A game with no surface writes Game<State, null> and returns [state, null].

type Transition<S> = (state: DeepReadonly<S>) => S;

A change to the state made from outside a frame: the shape update has, minus the frame. It is what a caller hands engine.apply to pose the game between frames, and what a debug surface’s poses are written as. A transition that returns undefined is refused the same way an update is.

initialize may return a promise of the pair, and the engine awaits it before running any frame. A return that is anything but a two-element array rejects initialize with an error naming the pair. A game that loads assets resolves them here and stores them in S, so every field of the state is present by the time a frame can observe it and the state type declares each of them as such.

A game that ends itself creates an AbortController in initialize, keeps it in S, and aborts it from update. Ending the game is then the game’s own state rather than an engine operation.

dt is seconds. Every quantity a 2D game writes down is per second, and seconds are what the game multiplies by.

interface InitApi<S = unknown> {
readonly input: {
register(name: string, binding: ActionBinding): void;
layout(): TouchLayout | null;
};
readonly audio: {
define(cue: string, spec: CueSpec): void;
load(cue: string, path: string): Promise<void>;
};
readonly assets: {
loadImage(path: string): Promise<ImageBitmap>;
loadAudio(path: string): Promise<AudioBuffer>;
load(path: string): Promise<Blob>;
resolve(path: string): string;
};
readonly diagnostics: {
register(
name: string,
source: (state: DeepReadonly<S>) => DiagnosticValue,
): void;
};
readonly events: EngineEvents;
viewport(): Viewport;
}

Everything a game declares once belongs here: its action bindings, its cue definitions, the assets it needs, and the values it wants on the overlay.

InitApi is generic over the game’s state so a diagnostic source is typed against it. A source is called with the state current at the read, because the state a frame leaves behind is a new value rather than the object initialize built.

The debug surface is the object a game returns beside its state from initialize, and the engine returns that same value from engine.debug. The engine holds it and nothing more: the shape is the game’s own, and the engine reads no member of it.

Because the surface arrives with the state, it is in place before any frame runs, a caller holding the engine finds it as soon as initialize resolves, and every caller that reads engine.debug holds the same object.

The surface holds no state of its own, because nothing holds a writable state. Its operations are written in the shape of update: a pose is a Transition<S> that takes the current state and returns the next, and a reading takes the current state and returns what it read. A caller drives a pose through engine.apply and a reading against engine.state.

interface CaromDebug {
readonly version: number;
setBallVelocity(state: DeepReadonly<State>, vx: number, vy: number): State;
snapshot(state: DeepReadonly<State>): Snapshot;
}
engine.apply((state) => engine.debug.setBallVelocity(state, 240, 0));
const snapshot = engine.debug.snapshot(engine.state);
interface UpdateApi {
readonly input: {
value(name: string): number;
pressed(name: string): boolean;
};
readonly audio: {
play(cue: string): void;
loop(cue: string): void;
stop(cue: string): void;
looping(cue: string): boolean;
setMuted(muted: boolean): void;
muted(): boolean;
};
frame(): FrameInfo;
viewport(): Viewport;
}

update reads input, plays cues, and advances the simulation. Nothing here draws, so a simulation can be stepped and inspected with no drawing surface involved in the result.

interface RenderApi {
readonly ctx: CanvasRenderingContext2D;
frame(): FrameInfo;
viewport(): Viewport;
}

ctx is cleared and already carries the logical viewport transform, so drawing is in logical coordinates. Nothing here reads input or plays a cue, so a frame’s audible and observable behavior is decided entirely by update.

ctx is the canvas’s own 2D context. A validator that wants the drawing operations rather than the pixels substitutes its own object for the context, and the engine passes through whatever the canvas returned.

interface EngineEvents {
on<K extends keyof EngineEventMap>(
event: K,
handler: (payload: EngineEventMap[K]) => void,
): () => void;
}
interface EngineEventMap {
"asset:loaded": { path: string; url: string };
"asset:failed": { path: string; url: string; reason: string };
"cue:played": { cue: string; t: number; gain: number };
"cue:looped": { cue: string; t: number; gain: number };
"cue:stopped": { cue: string; t: number };
"audio:unlocked": Record<string, never>;
}

on returns the function that removes the handler. A handler that throws is contained: the error reaches the console and the remaining handlers still run.

Subscription is what a caller uses to observe the engine as it works, in place of accumulating a record and reading it afterwards. Handlers are called synchronously at the moment the event happens, so a subscriber sees the frame the event belongs to.

interface FrameInfo {
count: number;
timeMs: number;
lastDeltaMs: number;
}
FieldMeaning
countFrames run since the loop started.
timeMsAccumulated simulated time in milliseconds: the sum of the deltas delivered.
lastDeltaMsThe delta the most recent frame was stepped by, in milliseconds.

timeMs and lastDeltaMs are milliseconds; the dt passed to update is seconds.

ConditionResult
initialize throws or rejectsengine.initialize rejects with the cause, and no frame runs
update or render throws under runThe error propagates to the host, and the loop schedules the next frame
update or render throws under advanceadvance rejects with the cause, and the remaining frames do not run
update returns undefined under runAn Error naming must return the next state propagates to the host, the loop schedules the next frame, and the engine keeps the state it had
update returns undefined under advanceadvance rejects with that Error, the remaining frames do not run, and the engine keeps the state it had

A throw under run leaves the loop alive so one bad frame does not freeze the game permanently. A throw under advance stops immediately, because a caller stepping an exact number of frames needs the failure rather than the frames after it.

An update that returns nothing is refused rather than held, because a game that mutated the view it was handed and returned nothing has advanced nothing the engine will read again, and holding undefined would turn that one mistake into a crash on an unrelated line of the next frame.

Game, Transition, DeepReadonly, InitApi, UpdateApi, RenderApi, EngineEvents, EngineEventMap, and FrameInfo are exported as types from @clockwyrks/simple-2d. DeepReadonly is the ts-essentials type, re-exported so a game names the view of its own state without a second import; game code may equally import it from ts-essentials directly.