Scaffold Render Command
Use when adding a new visual element to the game (sprite, tile, text, shape, etc.).
The Two-Part Rule
Every render concern is split across two functions — never combined:
| Part | Layer | Pure? | What it does |
|---|---|---|---|
| Projection | render/ |
✅ Pure | state → RenderCommand[] — describes what to draw |
| Executor | effects/ |
❌ Impure | RenderCommand → Canvas draw calls — actually draws it |
Never write to CanvasRenderingContext2D inside the projection. The projection must be fully testable without a Canvas.
Steps
1. Add the variant to RenderCommand
In packages/game/lib/core/actions.ts:
export type RenderCommand =
| {
readonly type: "fill-rect";
readonly xPos: number;
readonly yPos: number;
readonly width: number;
readonly height: number;
readonly color: string;
}
| { readonly type: "YOUR_COMMAND"; readonly /* payload fields */ };
2. Implement the projection (pure)
In packages/game/lib/render/project-render-commands.ts, compose the new command into the root foreground projection. Extract a helper only when it is reused or materially improves readability.
import type { RenderCommand } from "../core/actions.ts";
import type { GameState } from "../core/types.ts";
const projectYourThing = (state: GameState): ReadonlyArray<RenderCommand> =>
state.entities.map((e) => ({
type: "YOUR_COMMAND",
/* derive fields from state only — no Canvas access */
}));
export default projectYourThing;
The root projection returns the ordered ReadonlyArray<RenderCommand> for the current foreground frame. It does not emit the animated background; the background remains shell-rendered before foreground commands.
3. Implement the executor (impure)
In packages/game/lib/effects/execute-render-command.ts, add a case to the executor switch:
case "YOUR_COMMAND": {
context./* Canvas draw calls here */;
break;
}
End the switch with an exhaustiveness guard:
default: {
const _exhaustive: never = command;
return _exhaustive;
}
4. Write tests
import { describe, expect, it } from "vitest";
import projectYourThing from "./your-command.js";
describe("projectYourThing", () => {
it("produces the expected render commands from state", () => {
const state = /* minimal GameState */;
expect(projectYourThing(state)).toStrictEqual([
{ type: "YOUR_COMMAND", /* expected fields */ },
]);
});
});
No CanvasRenderingContext2D mock is needed for projection tests — the projection is pure. Add or update browser-provider tests in packages/game/lib/effects/execute-render-command.test.ts for the Canvas executor branch. If the command changes player/enemy observability, update renderStatsForState and its tests.
Prohibited
- Writing to
ctx.fillStyle/ctx.drawImage/ etc. insiderender/ - Reading from the DOM or Canvas inside the projection
- Returning
voidfrom the projection — it must returnReadonlyArray<RenderCommand> - Forgetting
RenderStatswhen the visible output changes. The effects renderer reports latest stats forwindow.__bruffTestApi.getRenderStats().