Skip to content

Creating the Engine

The engine is an ordinary dependency of the build’s workspace, already present in its package.json. One import brings in the factory.

import { createEngine } from "@clockwyrks/simple-2d";

The clock catalogue, the touch layout catalogue, and every type the engine names come from the same specifier.

import { createEngine, PacedClock, TOUCH_LAYOUTS } from "@clockwyrks/simple-2d";
import type {
CueSpec,
Engine,
EngineOptions,
Game,
} from "@clockwyrks/simple-2d";

The width and height handed to createEngine are the coordinate system the game is written in, and they stay fixed for the life of the build. Choose a size that suits the game’s aspect ratio and state every speed, size, and distance in those units; the engine fits that field onto whatever size the page gives the canvas.

A landscape action game is comfortable at 640 × 360, a puzzle board at 480 × 640. The number itself matters less than committing to one and writing every coordinate in it.

createEngine binds the game and builds the engine over the canvas. It runs synchronously and runs no game code, so the engine exists before anything the game does is observable. engine.initialize then runs the game’s own initialize and resolves once the state is built, and engine.run drives frames from there.

import { createEngine } from "@clockwyrks/simple-2d";
import { game } from "./game";
const canvas = document.querySelector<HTMLCanvasElement>("#game");
if (canvas === null) throw new Error("missing #game canvas");
const engine = createEngine({
canvas,
width: 640,
height: 360,
game,
background: "#101018",
});
await engine.initialize();
await engine.run();

That ordering is what lets a caller subscribe to engine events before the game loads anything, so a failed asset is observed as it happens rather than inferred afterwards.

engine.events.on("asset:failed", (event) => {
console.error(`asset ${event.path} failed: ${event.reason}`);
});
const opening = await engine.initialize();
OptionEffect
backgroundA CSS color the whole canvas is filled with before every frame. Left out, the frame clears to transparency and the page shows through behind the game.
layoutSelects a touch layout from TOUCH_LAYOUTS, whose vocabulary the game then registers as actions.
assetRootThe root every asset path resolves under. Defaults to assets/.
surfaceWhere the engine reads element size and device pixel ratio and attaches its listeners. Defaults to the canvas and its owning document.
const engine = createEngine({
canvas,
width: 640,
height: 360,
game,
layout: "dpad-4-two-buttons",
assetRoot: "assets/",
});

A build in the browser takes the default surface. Supplying one is how a validator runs the same engine over a canvas with no document behind it.

The element’s size is the page’s business and the fit is the engine’s. Give the canvas a size in whatever CSS units the page wants, inline or through a stylesheet, and leave the width and height attributes alone.

<canvas id="game" style="width: 100vw; height: 100vh; display: block"></canvas>

The engine pins a fixed pixel size onto the canvas only while its reported size still matches those attributes, which is what stops the backing store from feeding back into the next measurement. A canvas the page has sized keeps following its own rule, so it stays free to track the window. A canvas that fills a sized wrapper takes the same form, with width: 100%; height: 100%.

The engine reads the laid-out size at the top of every frame and resizes the backing store to match the device pixel ratio, so a window resize needs no handler and no code in the game.

A clock decides what each frame’s delta is, and the engine holds exactly one. A build that omits the option gets a WallClock, which reports the real time each frame took, clamped so a tab that stops receiving frames resumes as though the game paused for the gap.

const engine = createEngine({
canvas,
width: 640,
height: 360,
game,
clock: new PacedClock(60),
});

A PacedClock holds a cadence: it declines the ticks that arrive between grid slots and reports one fixed interval on the ticks it accepts. Choose it for a game whose feel depends on a steady rate, and for a build whose simulated time should match the rate it targets.

engine.setClock replaces the clock in place, and the frame counter and accumulated time carry over. The scripted clocks are what a validator installs to step a scenario reproducibly.

A game that runs for the life of the page never needs to be torn down. Two separate acts stop it when a build mounts the game into a view that goes away.

An AbortSignal passed to run halts the loop and leaves the engine usable, so the same teardown path that cancels everything else cancels the loop with it.

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

engine.destroy() halts the loop and detaches every listener. It is idempotent, and it resolves any promise run returned.

engine.destroy();