Skip to content

Cookbook

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;
}
};
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;
}
}
}
};
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);
}
}
};
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");
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";
};
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: [] });
// producer
const 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");
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);
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.

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 tuple
for (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.)

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 index
const 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 player
w.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.

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.

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);
}
};

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/sec
sch.add("update", movement_system, { every, name: "movement" });

See the reference table for common configurations.

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);