Cookbook
Movement: input → vel → pos
Section titled “Movement: input → vel → pos”import type { System } from "@f0rbit/forge";import { pos_c } from "@f0rbit/forge";import { player_c, vel_c } from "./components.ts";
const speed = 80;
export const player_input_system: System = (w, ctx) => { const [ax, ay] = ctx.input.vector("move.x", "move.y"); for (const [id] of w.query([player_c, vel_c] as const)) { w.set(id, vel_c, { dx: ax * speed, dy: ay * speed }); }};
export const movement_system: System = (w, ctx) => { const dt = ctx.time.fixed_dt; for (const [, p, v] of w.query([pos_c, vel_c] as const)) { p.x += v.dx * dt; p.y += v.dy * dt; }};Collision (radius)
Section titled “Collision (radius)”const radius_sq = 8 * 8;
export const collection_system: System = (w, ctx) => { const players = w.query([pos_c, player_c] as const).collect(); if (players.length === 0) return; const score = ctx.res.get(score_r); if (!score.ok) return;
for (const [, pp] of players) { for (const [cid, cp] of w.query([pos_c, coin_c] as const).collect()) { const dx = pp.x - cp.x; const dy = pp.y - cp.y; if (dx * dx + dy * dy <= radius_sq) { w.despawn(cid); score.value.value += 10; } } }};Collision (AABB)
Section titled “Collision (AABB)”const aabb_overlap = (a: { x: number; y: number; w: number; h: number }, b: typeof a): boolean => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
const box_c = component<{ w: number; h: number }>("box");
export const aabb_system: System = w => { const players = w.query([pos_c, box_c, player_c] as const).collect(); for (const [, pp, pb] of players) { const pbox = { x: pp.x, y: pp.y, w: pb.w, h: pb.h }; for (const [eid, ep, eb] of w.query([pos_c, box_c, enemy_c] as const).collect()) { const ebox = { x: ep.x, y: ep.y, w: eb.w, h: eb.h }; if (aabb_overlap(pbox, ebox)) w.despawn(eid); } }};Level setup (startup system)
Section titled “Level setup (startup system)”import type { System } from "@f0rbit/forge";
export const setup: System = (w, ctx) => { if (!ctx.res.has(score_r)) ctx.res.set(score_r, { value: 0 });
w.spawn( [pos_c, { x: 40, y: 90 }], [vel_c, { dx: 0, dy: 0 }], [player_c, true], ); for (const x of [120, 160, 200, 240, 280]) { w.spawn([pos_c, { x, y: 90 }], [coin_c, true]); }};
sch.add("startup", setup, "level.setup");Win/lose state (phase resource)
Section titled “Win/lose state (phase resource)”import { resource, type ResKey } from "@f0rbit/forge";
type Phase = { state: "playing" | "won" | "lost" };const phase_r: ResKey<Phase> = resource<Phase>("phase");
const startup: System = (_, ctx) => ctx.res.set(phase_r, { state: "playing" });
const win_check: System = (w, ctx) => { const phase = ctx.res.get(phase_r); if (!phase.ok || phase.value.state !== "playing") return; const remaining = w.query([coin_c] as const).collect().length; if (remaining === 0) phase.value.state = "won";};Spawn-on-event (event queue resource)
Section titled “Spawn-on-event (event queue resource)”type SpawnEvent = { kind: "enemy"; x: number; y: number };const spawn_q_r: ResKey<{ events: SpawnEvent[] }> = resource("spawn_queue");
const startup: System = (_, ctx) => ctx.res.set(spawn_q_r, { events: [] });
// producerconst trigger: System = (w, ctx) => { const q = ctx.res.get(spawn_q_r); if (q.ok && ctx.time.tick % 120 === 0) { q.value.events.push({ kind: "enemy", x: ctx.rng.int(0, 320), y: 0 }); }};
// consumer (drain at start of update)const consume: System = (w, ctx) => { const q = ctx.res.get(spawn_q_r); if (!q.ok) return; for (const ev of q.value.events) { if (ev.kind === "enemy") w.spawn([pos_c, { x: ev.x, y: ev.y }], [enemy_c, true]); } q.value.events.length = 0;};
sch.add("pre", consume, "spawn.consume");sch.add("update", trigger, "spawn.trigger");Score persistence
Section titled “Score persistence”import { engine_store } from "@f0rbit/forge/storage";import { z } from "zod";
const highscore_schema = z.object({ value: z.number() });const store = engine_store({ backend: "file", dir: "./saves" });
// ad-hoc Store<T> via `store({...})` — but for the engine_store stack you'd// register the schema differently. For ad-hoc top-level keys use `mem` or `file`:import { file } from "@f0rbit/forge/storage";const highscore_store = file({ dir: "./saves", schema: highscore_schema });
await highscore_store.save("default", { value: 1200 });const loaded = await highscore_store.load("default");if (loaded.ok) console.log("hi", loaded.value.value);Animation cycling
Section titled “Animation cycling”import { anim, anim_c } from "@f0rbit/forge";
const a = anim();sch.add("update", a.advance, "anim.advance");
w.set(player, anim_c, { atlas: "hero", sequence: "idle", frame: 0, t: 0, speed: 1, loop: true, done: false,});
// later:const r = a.play(w, player, "walk");if (!r.ok) console.warn("anim play failed", r.error);Periodic systems with schedule.add({ every })
Section titled “Periodic systems with schedule.add({ every })”For systems that run on a cadence (regen, AI ticks, projectile fire-rate, tile movement), declare the gate at registration instead of opening the body with if (ctx.time.tick % step !== 0) return;:
sch.add("update", regen_system, { every: 60, name: "regen" });sch.add("update", movement_system, { every: 10, name: "movement" });sch.add("update", spawn_wave, { every: 600, phase: 30, name: "spawn.wave" });{ every, phase? } — the wrapped system fires when ctx.time.tick % every === phase % every. Use phase to interleave two periodic systems with the same period instead of bunching them on tick 0.
For tile movement, pair with ticks_per_step so the cadence is expressed in cells/sec rather than ticks-per-step.
Marker-component queries
Section titled “Marker-component queries”query() auto-elides Component<true> marker slots from the yielded tuple — markers filter without contributing to the destructure shape:
// player_c is a marker; it filters but doesn't appear in the tuplefor (const [id, p, d] of w.query([pos_c, player_c, dir_c] as const).collect()) { // id: Id, p: Pos, d: Dir — no `true` slot to skip p.x += d.dx;}One array, one as const, identical destructure shape regardless of marker placement. (v0.2.0’s query_data is removed in favour of this auto-elision.)
Bulk-spawn levels with spawn_many
Section titled “Bulk-spawn levels with spawn_many”spawn_many accepts either a (count, factory) pair or an array of spec arrays. Use the array form when you already have an array of inputs (one entity per element):
const floors = /* Set<number> of floor cell-keys */;
// array form — one spec per source key (preferred when you have an iterable)const ids = w.spawn_many( [...floors].map(k => { const cell = g.unkey(k); const wp = g.cell_to_world(cell.x, cell.y); return [[pos_c, { x: wp.x, y: wp.y }], [floor_c, true]] as const; }),);
// count-and-factory form — best for grid-style fills parameterised by indexconst wall_ids = w.spawn_many(50, (i) => [ [pos_c, { x: i * 16, y: 0 }], [wall_c, true],]);Both forms return ids in spawn order. The runtime branches on Array.isArray(arg0).
Hard restart with clear() + despawn_marked
Section titled “Hard restart with clear() + despawn_marked”For “wipe and regenerate” level transitions, you usually want one of two behaviours:
// (1) wipe everything (player, walls, score entities, all of it)w.clear();spawn_level(w);
// (2) wipe only level entities, keep the playerw.despawn_marked(floor_c);w.despawn_marked(enemy_c);w.despawn_marked(item_c);spawn_level(w);world.clear() (added in v0.1.6) despawns every entity and clears every component store, but resources are untouched — so HUD score, current level, etc., persist across the wipe.
world.despawn_marked(...markers) is the surgical alternative — bulk-despawn every entity that has all of the listed markers, returning the count. Use it for level transitions where the player carries over.
Tile movement with g.move_tile
Section titled “Tile movement with g.move_tile”Axis-sliding tile movement with a collision predicate — the canonical “try X, then try Y from the resulting cell” loop. move_tile lives on the Grid record:
import { grid, ticks_per_step } from "@f0rbit/forge/grid";
const g = grid({ cols: 20, rows: 11, tile: 16 });const every = ticks_per_step(8, ctx.time.fixed_dt); // 8 cells/sec
const movement_system: System = (w) => { for (const [id, , d] of w.query([pos_c, dir_c, player_c] as const).collect()) { if (d.dx === 0 && d.dy === 0) continue; const r = g.move_tile(w, id, d, { blocked_by: (c) => walls.has(g.key(c.x, c.y)), }); if (r.ok && r.value.moved && r.value.to.x === exit.x && r.value.to.y === exit.y) { ctx.res.get(phase_r)!.value.state = "won"; } }};
sch.add("update", movement_system, { every, name: "movement" });opts.pos defaults to forge’s canonical pos_c; pass it for custom position components. See Move Tile for slide semantics and the full result shape.
Pixel-perfect FOV with g.line_of_sight
Section titled “Pixel-perfect FOV with g.line_of_sight”For roguelike sight, fog-of-war, sight cones — symmetric Bresenham line-of-sight, also a method on Grid:
import { grid, grid_index, grid_index_sync_system } from "@f0rbit/forge/grid";
const g = grid({ cols: 20, rows: 11, tile: 16 });const wall_idx = grid_index(w, pos_c, g, wall_c);sch.add("pre", grid_index_sync_system(wall_idx), "wall.idx");
const fov_system: System = (w) => { const players = w.query([pos_c, player_c] as const).collect(); for (const [, pp] of players) { const visible = g.line_of_sight({ from: g.world_to_cell(pp.x, pp.y), radius: 6, is_blocking: (c) => wall_idx.at(c) !== null, }); apply_visibility(visible); }};Delta-pattern for sprite visibility
Section titled “Delta-pattern for sprite visibility”Don’t sweep every tile every tick — keep the previous visible set and only toggle sprites whose visibility actually changed:
let last_visible: ReadonlySet<number> = new Set();
const apply_visibility = (visible: ReadonlySet<number>) => { for (const k of visible) if (!last_visible.has(k)) set_tile_visible(k, true); for (const k of last_visible) if (!visible.has(k)) set_tile_visible(k, false); last_visible = visible;};The full sweep allocates + does a get/spread/set round-trip per tile; the delta only touches the symmetric difference.
Calibrate movement speed with ticks_per_step
Section titled “Calibrate movement speed with ticks_per_step”Tile-step games need a “ticks between steps” gate. Designing in cells/sec instead of ticks decouples movement speed from grid resolution — change tile size and movement still feels the same.
import { ticks_per_step } from "@f0rbit/forge/grid";
const every = ticks_per_step(8, ctx.time.fixed_dt); // 8 cells/secsch.add("update", movement_system, { every, name: "movement" });See the reference table for common configurations.
Plugin pattern
Section titled “Plugin pattern”The (world, schedule) => void plugin shape is the canonical way to package a feature:
import type { Schedule, World } from "@f0rbit/forge";
export const game_plugin = (_w: World, sch: Schedule): void => { sch.add("startup", setup, "coin.setup"); sch.add("update", player_input_system, "coin.input"); sch.add("update", movement_system, "coin.movement"); sch.add("update", collection_system, "coin.collect");};Then in main.ts:
const r = await boot({...});if (r.ok) game_plugin(r.value.world, r.value.schedule);And in tests:
const h = harness({...});game_plugin(h.world, h.schedule);