Skip to content
TypeScriptDeterministicPIXI v8MIT

forge

TypeScript game engine on PIXI. Functional, composition-first, deterministic by construction. Replays are byte-identical. Tests run headless under bun test.

5
Subpath exports
Zero
Throws
60Hz
Fixed-tick

Core features

Functional ECS

Composition over inheritance. Factory functions return records of methods that close over local state. No this, no classes.

Deterministic

Integer-tick time, seeded mulberry32 RNG, sorted iteration. Same seed plus same actions equals byte-identical replays.

Pixel-perfect

Two-stage rendering on a logical canvas. Five camera modes — letterbox, fit, extend, expand, stretch — and one-pixel scaling.

Headless-testable

bun test against harness(). No DOM, no canvas, no browser. Replay JSON files double as integration fixtures.

Action-first input

Keyboard, mouse, gamepad unified through bindings. Replays portable across rebindings — actions are the recording, not keys.

Result-typed

@f0rbit/corpus Result<T, E> at every fallible boundary. Zero throws in engine code. The renderer edge wraps PIXI in try_catch.

Quick start

Install
Terminal window
bun add @f0rbit/forge
bun add pixi.js # only for @f0rbit/forge/pixi
Boot
import { component, pos_c } from "@f0rbit/forge";
import { boot, sprite_c } from "@f0rbit/forge/pixi";
import { presets } from "@f0rbit/forge/presets";
const player_c = component<true>("player");
const r = await boot({
mount: "#root",
window: { width: globalThis.innerWidth, height: globalThis.innerHeight },
camera: { design: { width: 320, height: 180 }, mode: "letterbox" },
bindings: presets.movement2d,
});
if (!r.ok) throw new Error(`boot failed: ${r.error.kind}`);
const app = r.value;
Play
app.world.spawn(
[pos_c, { x: 160, y: 90 }],
[player_c, true],
[sprite_c, { texture: "__default__", frame: "__default_0__", anchor: { x: 0.5, y: 0.5 } }],
);
app.schedule.add("update", (w, ctx) => {
const [dx, dy] = ctx.input.vector("move.x", "move.y");
for (const [, p] of w.query([pos_c, player_c] as const)) {
p.x += dx * 60 * ctx.time.fixed_dt;
p.y += dy * 60 * ctx.time.fixed_dt;
}
}, "player.move");
app.start();

Why forge?

Orchestrator-friendly

The schedule is a flat record of insertion-ordered systems. Plugins are install functions over (world, schedule). Composition all the way down — easy to reason about, easy to test.

Single shipped package

One npm install gets the whole engine. Five subpath exports keep the surface curated: core, pixi, debug, storage, presets. Nothing leaks accidentally.

Terminal-first dev

No GUI editor, no node-graph, no scripting language. Vim plus the in-game palette. bun test plus replay JSON. Edit TypeScript, restart, replay.

Replay as fixture

Action streams are the recording, not raw events. Save a winning run once, replay it on every CI. Bug repros become 12-second JSON files.

Determinism contract

Same seed plus same fixed_dt plus same recorded action stream plus same code equals byte-identical world hash on every run.

This is the engine's hard guarantee and the foundation of replay-as-fixture testing. Replay tests are cheap regression coverage — record a winning run once, replay it on every CI. Save/load round-trips are bit-exact. Bug repros become "run this 12-second JSON against the latest code" instead of "WASD a bunch and see if the player gets stuck".

Guaranteed
  • Integer time.tick
  • Insertion-ordered systems
  • Sorted entity iteration
  • Seeded mulberry32 RNG
  • Action streams over events
Banned outside src/pixi/
  • Date.now()
  • Math.random()
  • setTimeout / setInterval
  • pixi.js imports
  • Async game logic (no await in systems)

Next steps