A Minimal Game
The smallest complete build: a page holding a canvas, a boot module, and a game that slides a rectangle across the field and reflects it off both walls. It registers no action, loads no asset, and defines no cue, so what remains is the whole of what a build must supply.
index.html
Section titled “index.html”<!doctype html><html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Drifter</title> </head> <body style="margin: 0; background: #05060a"> <canvas id="game" style="display: block; width: 100vw; height: 100vh" ></canvas> <script type="module" src="./src/main.ts"></script> </body></html>The canvas carries its size inline, so the engine leaves the element free to follow the window and fits the logical field into whatever size it reports.
src/main.ts
Section titled “src/main.ts”import { createEngine } from "@clockwyrks/simple-2d";import { drifter } from "./game";
const canvas = document.querySelector<HTMLCanvasElement>("#game");if (canvas === null) throw new Error("missing canvas #game");
const engine = createEngine({ canvas, width: 640, height: 360, background: "#05060a", game: drifter,});
await engine.initialize();await engine.run();createEngine runs no game code, initialize runs the game’s initialize and
builds the state, and run drives frames off the host’s frame callback until
the engine is destroyed. Omitting the clock installs a
WallClock, so each frame is worth the time
that actually elapsed.
src/game.ts
Section titled “src/game.ts”import type { Game, RenderApi, UpdateApi } from "@clockwyrks/simple-2d";import type { DeepReadonly } from "ts-essentials";
const BOX = 48;const SPEED = 220;
interface State { readonly x: number; readonly y: number; readonly vx: number;}
export const drifter: Game<State, null> = { initialize(): [State, null] { return [{ x: 0, y: 156, vx: SPEED }, null]; },
update(state: DeepReadonly<State>, api: UpdateApi, dt: number): State { const limit = api.viewport().width - BOX; const x = state.x + state.vx * dt;
if (x < 0) return { ...state, x: -x, vx: SPEED }; if (x > limit) return { ...state, x: 2 * limit - x, vx: -SPEED }; return { ...state, x }; },
render(state: DeepReadonly<State>, api: RenderApi): void { const { ctx } = api; ctx.fillStyle = "#7fd1ff"; ctx.fillRect(state.x, state.y, BOX, BOX); },};What the game owns
Section titled “What the game owns”State is the whole of what the three functions share, and initialize returns
it complete, so update and render read every field directly. Both receive
it as a DeepReadonly view; update returns the next value and render
returns nothing, so ts-essentials is the one import beside the engine.
update multiplies by dt in seconds and reflects the overshoot back into the
field, which keeps the outcome the same whatever step size the clock delivers.
Each branch spreads the current state into a new one, and the engine keeps the
value returned as the state the next frame receives. Reading the bound from
api.viewport() keeps the design width in one place, the width passed to
createEngine.
render draws in logical units against a context that arrives cleared and
already carrying the viewport transform, so the rectangle lands in the same
place on every display.