| name | scaffold-render-command |
| description | Add a new RenderCommand variant to the retained scene description and wire its Canvas executor in the impure shell |
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 };
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",
}));
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.;
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 = ;
expect(projectYourThing(state)).toStrictEqual([
{ type: "YOUR_COMMAND", },
]);
});
});
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. inside render/
- Reading from the DOM or Canvas inside the projection
- Returning
void from the projection — it must return ReadonlyArray<RenderCommand>
- Forgetting
RenderStats when the visible output changes. The effects renderer reports latest stats for window.__bruffTestApi.getRenderStats().