Skip to content

Game Instance

A game definition names the level registry, the level the engine opens first, and the game instance class. The game instance is the object that represents the whole game: the engine constructs it once, from the class the definition names, and keeps it for its lifetime. Every level transition happens underneath it, so the instance is the one framework object that outlives a world.

interface GameDefinition<D = unknown> {
instance?: GameInstanceClass<D>;
levels: Readonly<Record<string, LevelDefinition>>;
startLevel: string;
}
FieldDefaultMeaning
instanceGameInstanceThe class constructed once and kept across every level. Its initialize fixes D, the debug surface.
levels—The level registry, keyed by level name. At least one entry.
startLevel—The level engine.initialize opens. A key of levels.

The definition is what EngineOptions.game carries, and one engine drives one definition for its lifetime. Each entry of levels is a level definition: a description rather than a live object. Opening one builds a world, and the definition stays available for every later transition back to it.

D is the type of the debug surface the instance’s initialize returns, and createEngine infers it from the definition. A game with no surface is a GameDefinition<null>.

type GameInstanceClass<D = unknown> = new () => GameInstance<D>;
class GameInstance<D = unknown> {
readonly engine: Engine<D>;
readonly events: EngineEvents;
initialize(api: InitApi): D | Promise<D>;
worldOpened(world: World): void;
worldClosing(world: World): void;
shutdown(): void;
}
MemberCalled
initializeOnce, before the start level opens. Returns the debug surface.
worldOpenedAfter each world’s game mode has begun play.
worldClosingBefore each world’s actors end play.
shutdownOnce, from engine.destroy.

A game supplies its own subclass through GameDefinition.instance. The class takes no constructor arguments, and the engine constructs it with none. Omitting the field uses GameInstance itself, which suits a game whose whole state fits in its worlds.

engine is assigned before initialize runs, so a constructor sets defaults and nothing more. Anything that reads the engine, loads an asset, or registers a binding belongs in initialize. events is the same broadcaster that engine.events reaches.

The base class’s initialize returns null and its other methods do nothing, so a subclass overrides only what it needs. initialize may return a promise, and the engine awaits it before the start level opens.

class Arcade extends GameInstance<null> {
best = 0;
override initialize(api: InitApi): null {
api.input.register("thrust", { keys: ["KeyW", "ArrowUp"] });
return null;
}
override worldOpened(world: World): void {
world.diagnostics.register("best", () => this.best);
}
}

The instance carries what must survive travel between levels: the action bindings and cue definitions declared from InitApi, the assets the whole game needs, the diagnostic sources the overlay should always show, and the figures a game keeps across matches, such as a high score or the number of levels completed.

A value scoped to one match lives on the game state instead, which is rebuilt with its world. Score, phase, elapsed match time, and per-player figures belong there, so a transition clears them without the instance doing anything.

worldOpened and worldClosing are where the two meet. A game reads the outgoing world’s state into the instance from worldClosing, and seeds the incoming world from worldOpened.

initialize returns the game’s debug surface: the operations a case’s checks use to pose a situation and read it back. The engine holds the value unchanged and hands it back as engine.debug, reading no member of it, so its shape is whatever the instance declares as D. A game with no surface returns null, and undefined is refused.

Each operation is a method that acts on the live world through this.engine. engine.world follows every transition, so an operation reads it at the moment of the call rather than holding a world of its own. A pose takes only its own arguments and returns nothing; a reading takes nothing and returns plain data.

interface Debug {
startMatch(mode: Mode): void;
setBallPosition(x: number, y: number): void;
setBallVelocity(vx: number, vy: number): void;
snapshot(): Snapshot;
}
class Arcade extends GameInstance<Debug> {
override initialize(api: InitApi): Debug {
api.input.register("thrust", { keys: ["KeyW", "ArrowUp"] });
const ball = (): Ball => this.engine.world.byTag("ball")[0] as Ball;
return {
startMatch: (mode) => (this.engine.world.mode as Arena).start(mode),
setBallPosition: (x, y) => {
const { transform } = ball();
transform.x = x;
transform.y = y;
},
setBallVelocity: (vx, vy) => {
const { velocity } = ball();
velocity.x = vx;
velocity.y = vy;
},
snapshot: () => ({
score: (this.engine.world.state as ArenaState).score,
}),
};
}
}

A pose arranges the world through the same systems play uses, spawning actors, moving transforms, driving the game mode, and possessing pawns, and leaves the outcome to the frames that follow. The surface’s shape comes from the case’s instrumentation spec; the Debug Surface page covers what belongs on it.

interface InitApi {
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: () => DiagnosticValue): void;
};
readonly events: EngineEvents;
viewport(): Viewport;
}

Everything declared here belongs to the whole game and survives every level transition: the action bindings, the cue definitions, the assets the instance holds, and the diagnostic sources the overlay reads.

class Arcade extends GameInstance<null> {
sheet!: ImageBitmap;
override async initialize(api: InitApi): Promise<null> {
api.input.register("thrust", { keys: ["KeyW", "ArrowUp"] });
api.audio.define("score", { freq: 660, durationMs: 90 });
this.sheet = await api.assets.loadImage("sprites/ship.png");
return null;
}
}

An asset only one level needs is loaded in that level’s load instead, which the engine awaits before any actor of that level exists.

ConditionResult
initialize throws or rejectsengine.initialize rejects with the cause, and no frame runs
initialize returns undefinedengine.initialize rejects with an Error naming the debug surface, and no frame runs
engine.debug read before engine.initialize resolvesError naming the ordering

A failure here reaches the caller that built the engine, so a build reports a missing asset or a bad binding at the point it was asked for rather than as a game that runs and draws nothing.

GameInstance is exported as a class from @clockwyrks/structured-2d. GameDefinition, GameInstanceClass, and InitApi are exported as types from the same specifier.