Drawing
Everything a game draws goes inside render(state, api) and reaches the canvas
through api.ctx. The context arrives cleared and already carrying the viewport
transform, so drawing is in logical coordinates: (0, 0) is the top-left of the
design field and (width, height) is its bottom-right, whatever size the canvas
element happens to be.
import type { Game } from "@clockwyrks/simple-2d";
interface State { readonly ball: { readonly x: number; readonly y: number; readonly vx: number; readonly vy: number; };}
const game: Game<State, null> = { initialize() { return [{ ball: { x: 320, y: 180, vx: 180, vy: 90 } }, null]; }, update(state, api, dt) { const vp = api.viewport(); const y = state.ball.y + state.ball.vy * dt; const vy = y < 0 || y > vp.height ? -state.ball.vy : state.ball.vy; return { ball: { ...state.ball, x: state.ball.x + state.ball.vx * dt, y, vy }, }; }, render(state, api) { const { ctx } = api; const vp = api.viewport();
ctx.fillStyle = "#1b1b2a"; ctx.fillRect(0, 0, vp.width, vp.height);
ctx.fillStyle = "#7fd1ff"; ctx.beginPath(); ctx.arc(state.ball.x, state.ball.y, 6, 0, Math.PI * 2); ctx.fill(); },};api.viewport() reports the design size the engine was created with together
with the fit that size currently sits under. The letterbox bars and the device
pixel ratio are folded into the transform, so a build states every coordinate,
speed, and size in design units and leaves the element’s own size to the
engine.
Render only draws
Section titled “Render only draws”render receives the state as a DeepReadonly view and returns nothing, so
the picture is a function of the state update returned and the compiler
refuses a render that assigns into it. A value the drawing depends on, such as
an animation phase or a highlighted entity, is computed in update and carried
in the state.
RenderApi carries the context, the frame counter, and the viewport. Input and
audio are absent from it, so a frame’s response to the player is decided
entirely by update.
Every frame draws the whole picture
Section titled “Every frame draws the whole picture”The engine clears the canvas before each frame, so render starts from a blank
field and lays down everything that should be visible. Every frame is a complete
picture, which leaves a build free of dirty-rectangle bookkeeping and makes a
single frame enough to describe what the game looked like at that instant.
Draw back to front: the background, then the play field, then entities, then any in-game HUD.
import type { RenderApi } from "@clockwyrks/simple-2d";import type { DeepReadonly } from "ts-essentials";
function render(state: DeepReadonly<Board>, api: RenderApi): void { const { ctx } = api; drawField(ctx, api.viewport()); for (const brick of state.bricks) drawBrick(ctx, brick); drawPaddle(ctx, state.paddle); drawBall(ctx, state.ball); drawScore(ctx, state.score);}Context state
Section titled “Context state”The transform is replaced at the top of every frame, so a translate, rotate,
or scale left behind at the end of render is discarded rather than
compounding into the next frame. Balance save and restore around a
transformed subtree anyway, so the rest of that same frame draws where it meant
to.
function render(state: DeepReadonly<Flight>, api: RenderApi): void { const { ctx } = api;
ctx.save(); ctx.translate(state.ship.x, state.ship.y); ctx.rotate(state.ship.angle); ctx.fillStyle = "#f5d76e"; ctx.fillRect(-10, -6, 20, 12); ctx.restore();
drawHud(ctx, state);}Fill and stroke styles, the font, the alpha, and the line width carry across draw calls within a frame. Set each one where the drawing that depends on it happens, so a helper that changes a style leaves its caller drawing in the color it asked for.
Font sizes are logical units like every other measurement, so text scales with the rest of the picture as the window changes size. Set the font, the fill, and the alignment together, and place the text at a coordinate in the design field.
function drawScore(ctx: CanvasRenderingContext2D, score: number): void { ctx.font = "16px monospace"; ctx.fillStyle = "#ffffff"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(`SCORE ${score}`, 320, 28);}Debug text belongs on the overlay instead, which the engine draws over the finished picture in device pixels and a reviewer toggles on demand.
Reading the pointer
Section titled “Reading the pointer”A pointer event reports a position in CSS pixels relative to the browser
viewport. The engine maps it into the game’s own coordinates before the game
sees it — the device pixel ratio, the letterbox bars, and the scale are all
applied inside the engine — so update reads positions on the same axes
render draws on, through UpdateApi.input.
import { createEngine } from "@clockwyrks/simple-2d";import type { Game } from "@clockwyrks/simple-2d";
interface Point { readonly x: number; readonly y: number;}
interface Aiming { readonly aim: Point;}
const game: Game<Aiming, null> = { initialize: () => [{ aim: { x: 320, y: 180 } }, null], update: (state, api, dt) => { const pointer = api.input.pointer(); const aimed = pointer.down ? { ...state, aim: { x: pointer.x, y: pointer.y } } : state; return stepTowards(aimed, dt); }, render: (state, api) => drawCrosshair(api.ctx, state.aim),};A point inside a letterbox bar maps outside 0..width or 0..height, so a
game either clamps it or treats it as a miss. A game that reacts to the path
the pointer traveled — drawing, dragging along a route — reads
api.input.pointerSamples() and resolves each sample on its own; the snapshot
above is where the sweep ended, which is all aiming needs.
The pointer suits aiming and direct manipulation, where the position itself is the input. Everything else a case checks belongs behind a registered action, which a validator drives by name.