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 shapestype 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..1const 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 + Renderableclass 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.
Sprites
Section titled “Sprites”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 drawsworld.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();Anchors
Section titled “Anchors”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.
Transform overlay
Section titled “Transform overlay”The renderer optionally reads extrinsic transform components:
RotationDef { angle: number }(radians). Rotates aroundPositionDef.ScaleDef { x: number; y: number }. Scales aroundPositionDef.OpacityDef { value: number }in[0, 1]. MultipliesglobalAlpha.RenderOrderDef { value: number }. Ascendingvaluedraws 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).
Camera view + culling
Section titled “Camera view + culling”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 plainview, never importing the camera module. Drive it from aCamerawithcameraToView(cam)(seemodules/camera), or from any bespoke pan/zoom state. - Cull bounds.
rect,circleandspriteare culled by their world AABB (a sprite’s isdw × dh, or its atlas frame size when either is omitted). An entity with aRotationDeforScaleDefis 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.textandpolygonare 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.
Validation
Section titled “Validation”RenderableDefdeclaresrequires: ['position']— not enforced at registration, and the renderer only draws entities carrying both. In development, setting arenderablewithout apositionlogs a warning.- Polygons require at least 2 points. Open polygons (
closed: false) may not setfill— Canvas2D’sfill()auto-closes paths, which would produce an unintended shape; the validator rejects this with a clear message pointing toclosed: trueor removingfill.
What ships in v2
Section titled “What ships in v2”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
CameraDefzoom(theviewhook now exists; snake just needs to adopt it).
Notes:
- An entity whose
Renderablehas neitherfillnorstrokeset 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 afterrenderer.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 frommodules/cameraor bespoke pan/zoom.