Skip to content

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.

<!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.

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.

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);
},
};

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.