Rendering
The framework provides two renderers (PixiRenderer for WebGL/WebGPU and CanvasRenderer for Canvas 2D), GameLoop for the update/render cycle, and Camera for world-space transforms.
PixiRenderer
The recommended renderer for new games. Uses PixiJS 8 for hardware-accelerated rendering. pixi.js is an optional peer dependency, so install it first:
npm install pixi.jsimport { PixiRenderer, GameLoop } from "quantum-forge-engine/rendering";
class MyRenderer extends PixiRenderer {
protected draw(state: GameState) {
// this.graphics is a PixiJS Graphics object, cleared each frame
this.graphics.circle(state.player.x, state.player.y, 10).fill("#fff");
// drawText manages PixiJS Text objects by key
this.drawText("score", `Score: ${state.score}`, 10, 10, {
fill: "#fff",
fontSize: 16,
});
// Persistent display objects (sprites, containers) go on this.stage
}
}
const renderer = new MyRenderer({
canvas: document.getElementById("game-canvas") as HTMLCanvasElement,
width: 800,
height: 600,
backgroundColor: 0x000000,
maxDpr: 2,
logger,
});
// PixiJS requires async init
await renderer.init();There is no this.app. PixiRenderer owns the renderer and its root container, so persistent objects are added with this.stage.addChild(...) and immediate-mode shapes go through this.graphics. getMetrics() reports canvas size, DPR, and how many text objects are active or pooled.
WARNING
await renderer.init() must complete before starting the game loop. PixiJS initializes the WebGL/WebGPU context asynchronously.
CanvasRenderer
For simpler games or Canvas 2D use cases:
import { CanvasRenderer, drawCircle } from "quantum-forge-engine/rendering";
class MyRenderer extends CanvasRenderer {
render(state: GameState) {
this.clear("#000");
drawCircle(this.ctx, state.player.x, state.player.y, 10, "#fff");
}
}
const renderer = new MyRenderer({ canvas, width: 800, height: 600, maxDpr: 2, logger });render(state, deltaTime?) is abstract, so every subclass implements it. The base class gives you this.ctx and this.canvas as protected members, clear(color?), resize(cssWidth, cssHeight), and getMetrics(). Passing width and height runs an initial resize, and maxDpr caps the backing-store scale on high-DPI screens.
The same subpath exports plain drawing helpers: drawCircle, drawRect, drawLine, drawText, lerp, clamp.
GameLoop
Manages the update/render cycle at a target frame rate:
const loop = new GameLoop({
update: (deltaTime: number) => {
input.poll();
// Update game logic
},
render: () => {
renderer.render(engine.getState());
},
targetFps: 60,
logger,
});
loop.start();
loop.isRunning(); // true
loop.getFps(); // measured, for a debug overlay
// loop.stop();deltaTime is in seconds. At 60 FPS, deltaTime ≈ 0.0167.
For games that drive their own frames, DeltaTimeTracker gives the same delta without the loop: getDelta() returns seconds since the last call, capped at 0.1.
Camera
Camera converts between world and screen space. x and y are the top-left corner of the view, width and height are required, and there is no zoom.
import { Camera } from "quantum-forge-engine/rendering";
const camera = new Camera({
width: 800,
height: 600,
bounds: { minX: 0, minY: 0, maxX: 3200, maxY: 2400 },
});
camera.follow(state.player); // smoothing defaults to 0.1
camera.centerOn(state.player.x, state.player.y); // hard snap
if (camera.isVisible(enemy.x, enemy.y, 32)) {
const p = camera.worldToScreen(enemy.x, enemy.y);
this.graphics.circle(p.x, p.y, enemy.radius).fill(enemy.color);
}bounds clamps the view to the world rectangle. For Canvas 2D, applyTransform(ctx) and resetTransform(ctx) translate the context instead of converting each point. For PixiJS, applyToContainer(this.stage) does the same for a container.
Rendering from state
Renderers derive visuals from state. Game logic and its results stay in the engine.
// Correct: read state, draw
protected draw(state: GameState) {
for (const enemy of state.enemies) {
this.graphics.circle(enemy.x, enemy.y, enemy.radius).fill(enemy.color);
}
}
// Wrong: deciding and remembering in the renderer
protected draw(state: GameState) {
enemy.health -= 1; // game logic belongs in a helper
this.lastKnownPositions[enemy.id] = enemy.x; // don't keep a second source of truth
}Camera transforms are the exception. Converting world coordinates to screen coordinates is a rendering concern, so camera.worldToScreen() inside draw() is fine.
Quantum visualization
For quantum objects, map probability to visual properties such as opacity:
protected draw(state: GameState) {
for (const ball of state.balls) {
const alpha = ball.isQuantum ? ball.existenceProbability : 1.0;
this.graphics.circle(ball.x, ball.y, ball.radius).fill({
color: ball.color,
alpha,
});
}
}The existenceProbability comes from the engine state. An engine helper reads it once per update with ball.exists.probability(true), which never collapses anything, and the renderer only draws what the state holds.