Skip to content

render-canvas2d

Import from @pierre/ecs/modules/render-canvas2d.

Default Canvas 2D renderer for ECS entities that carry PositionDef + RenderableDef. Ships alongside the Renderer<TCtx> interface from @pierre/ecs/renderer and the AnimationFrameTickSource from @pierre/ecs/modules/tick.

// Component: discriminated union of shapes
type Renderable =
| { kind: 'rect'; w: number; h: number; anchor?: 'top-left' | 'center';
fill?: string; stroke?: string; lineWidth?: number;
blendMode?: GlobalCompositeOperation }
| { kind: 'circle'; radius: number; anchor?: 'top-left' | 'center';
fill?: string; stroke?: string; lineWidth?: number;
blendMode?: GlobalCompositeOperation }
| { kind: 'polygon'; points: readonly { x: number; y: number }[]; closed: boolean;
fill?: string; stroke?: string; lineWidth?: number;
blendMode?: GlobalCompositeOperation }
| { kind: 'text'; text: string; font: string;
align?: CanvasTextAlign; baseline?: CanvasTextBaseline;
fill?: string; stroke?: string; lineWidth?: number;
blendMode?: GlobalCompositeOperation }
| { kind: 'sprite'; atlas: string; frame: string;
dw?: number; dh?: number; anchor?: 'top-left' | 'center';
blendMode?: GlobalCompositeOperation };
const RenderableDef: ComponentDef<Renderable>;
// Optional overlay components (see "Transform overlay" below)
const OpacityDef: ComponentDef<{ value: number }>; // 0..1
const RenderOrderDef: ComponentDef<{ value: number }>; // ascending = later = on top
// Optional camera transform on the render context (see "Camera view" below)
interface RenderView { x: number; y: number; zoom?: number }
// Renderer: draws every entity with Position + Renderable
class Canvas2DRenderer implements Renderer<{
ctx2d: CanvasRenderingContext2D;
world: EcsWorld;
atlases?: SpriteFrameSource;
view?: RenderView;
}> {
render(ctx): void;
}

The renderer also reads, when registered, RotationDef and ScaleDef from @pierre/ecs/modules/transform.

The sprite kind draws a sub-rectangle of an atlas image, referenced by atlas + frame name (kept as strings so Renderable stays serializable). Supply an atlas registry via the render context’s optional atlases field — any object satisfying SpriteFrameSource (getFrame(atlas, frame) → { image, sx, sy, sw, sh }). The TextureAtlasRegistry from @pierre/ecs/modules/texture-atlas satisfies it structurally.

renderer.render({ atlases: registry, ctx2d, world });
world.getStore(RenderableDef).set(id, { atlas: 'space', frame: 'ship.png', kind: 'sprite' });

dw/dh default to the frame’s natural pixel size; anchor defaults to 'center'. A sprite whose atlas/frame is unresolved (or when no registry is supplied) draws nothing.

import { AnimationFrameTickSource } from '@pierre/ecs/modules/tick';
import { Canvas2DRenderer, RenderableDef } from '@pierre/ecs/modules/render-canvas2d';
import { PositionDef } from '@pierre/ecs/modules/transform';
world.registerComponent(PositionDef);
world.registerComponent(RenderableDef);
world.getStore(PositionDef).set(coinId, { x: 120, y: 200 });
world.getStore(PositionDef).set(platformId, { x: 320, y: 400 });
// Data-driven draws
world.getStore(RenderableDef).set(coinId, {
kind: 'circle', radius: 6, fill: '#f4c542',
});
world.getStore(RenderableDef).set(platformId, {
kind: 'rect', w: 80, h: 16, fill: '#5a6577', stroke: '#8aa0bd',
});
const renderer = new Canvas2DRenderer();
const rafSource = new AnimationFrameTickSource();
rafSource.subscribe(() => {
ctx2d.fillStyle = '#000';
ctx2d.fillRect(0, 0, W, H); // consumer clears
renderer.render({ ctx2d, world }); // module draws shapes
drawHud(ctx2d, state); // consumer overlays HUD
});
rafSource.start();

Each variant has a default anchor (the meaning of PositionDef for that shape). Override with anchor: 'top-left' | 'center' where applicable.

Variant Default anchor anchor: 'top-left' anchor: 'center'
rect top-left draws (x, y) → (x+w, y+h) draws (x-w/2, y-h/2) → (x+w/2, y+h/2)
circle center draws circle centred on (x+r, y+r) (useful when positions are AABB top-lefts) draws circle centred on (x, y)
polygon pivot at (x, y) — polygon-local points are offsets — —
text pivot at (x, y) — final alignment controlled by textAlign/textBaseline — —

This matches the way physics-aware entities are usually anchored: bounding-box entities (platforms, players) sit at their top-left corner; round entities (bullets, balls, planets) track their centre. When a game keeps physics data at AABB top-left but wants to render a circle (e.g. a platformer coin), set anchor: 'top-left' on the circle so the drawn centre lands at the AABB midpoint without any bespoke render/pickup logic.

The renderer optionally reads extrinsic transform components:

  • RotationDef { angle: number } (radians). Rotates around PositionDef.
  • ScaleDef { x: number; y: number }. Scales around PositionDef.
  • OpacityDef { value: number } in [0, 1]. Multiplies globalAlpha.
  • RenderOrderDef { value: number }. Ascending value draws later (on top). Ties fall back to component-store insertion order.

All four are optional. The renderer only pays for features the world actually uses: when no entity carries RenderOrderDef, the sort is skipped entirely; rotation/scale are only applied when non-default; when neither RotationDef, ScaleDef, OpacityDef, nor blendMode apply to an entity, the draw path is byte-identical to V1 (no per-entity save/restore).

Pass an optional view on the render context to draw the whole scene through a camera transform: (x, y) is the world coordinate at the viewport’s top-left and zoom (default 1) magnifies, so screen = (world − {x, y}) · zoom. With a view set, entities whose world AABB falls outside the visible rect are culled before drawing — cheap off-screen rejection for large scenes.

renderer.render({ ctx2d, world, view: { x: camX, y: camY, zoom: 2 } });
  • Decoupled from modules/camera. The renderer takes a plain view, never importing the camera module. Drive it from a Camera with cameraToView(cam) (see modules/camera), or from any bespoke pan/zoom state.
  • Cull bounds. rect, circle and sprite are culled by their world AABB (a sprite’s is dw × dh, or its atlas frame size when either is omitted). An entity with a RotationDef or ScaleDef is culled by a conservative bound instead (the circle around its position that contains the shape at any angle), so a rotated or scaled shape is never culled while part of it can be visible. text and polygon are never culled (drawn regardless of the view rect).
  • No view → no transform, no cull — fully back-compatible with callers that bake their own transform or draw in raw world/screen coords.
  • RenderableDef declares requires: ['position'] — not enforced at registration, and the renderer only draws entities carrying both. In development, setting a renderable without a position logs a warning.
  • Polygons require at least 2 points. Open polygons (closed: false) may not set fill — Canvas2D’s fill() auto-closes paths, which would produce an unintended shape; the validator rejects this with a clear message pointing to closed: true or removing fill.

Shape variants: rect, circle, polygon, text. Per-variant anchor for rect/circle. Extrinsic overlays: RotationDef, ScaleDef, OpacityDef, RenderOrderDef, per-entity blendMode.

Not in the module yet (see docs/backlog.md for status):

  • Tilemap kind — a batched tilemap renderable; planned.
  • Canvas filters (ctx.filter) — deferred; request-driven.
  • Snake migration — push the cells→pixels scale onto a CameraDef zoom (the view hook now exists; snake just needs to adopt it).

Notes:

  • An entity whose Renderable has neither fill nor stroke set is skipped (nothing drawn). Supply at least one to make a shape visible.
  • The renderer wraps its draw loop in ctx2d.save() / restore() so your canvas state after renderer.render() is unchanged. Per-entity isolation is only applied when needed (transform/opacity/blend).

Consumers handle:

  • Canvas clear / background
  • HUD, score, game-over screens
  • Pixel scaling and DPI. The camera transform + off-screen cull ship here via the optional view (see Camera view + culling); drive it from modules/camera or bespoke pan/zoom.