Line of Sight
g.line_of_sight() is a Bresenham-based field of view. For every cell within Chebyshev radius, it walks a line from from to that cell and includes it iff every intermediate step is non-blocking. Symmetric by construction: A sees B iff B sees A.
import { grid } from "@f0rbit/forge/grid";
const g = grid({ cols: 20, rows: 11, tile: 16 });const blocked = new Set<number>(/* keys of opaque walls */);
const visible = g.line_of_sight({ from: { x: 5, y: 4 }, radius: 6, is_blocking: (cell) => blocked.has(g.key(cell.x, cell.y)),});
if (visible.has(g.key(8, 6))) { /* (8,6) is visible */ }type FovOpts = { from: Cell; radius: number; // Chebyshev radius is_blocking: (cell: Cell) => boolean; // true = sight cannot pass include_origin?: boolean; // default true};
type Grid = { // ... line_of_sight: (opts: FovOpts) => ReadonlySet<number>;};The returned Set<number> keys are produced by g.key(x, y). Use g.unkey(k) to recover the cell.
FovOpts no longer carries a grid field — the method closes over the receiver.
Properties
Section titled “Properties”- Symmetric — opaque blockers stop the line on entry, so visibility is a strict relation rather than a half-open one.
- Cheap —
O(radius² · radius)cell visits. For radius 8 on a 20×11 board that’s well under a millisecond. - Deterministic — pure function of the inputs; no
Math.random, no time, no allocations beyond the result set. - Origin handling —
include_origin: true(default) always addsfrom. The blocker test is not applied tofromor to the target cell itself; only to intermediate steps.
Pairing with grid_index
Section titled “Pairing with grid_index”When opaque entities live as ECS components, derive is_blocking from a grid_index:
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(world, pos_c, g, wall_c);schedule.add("pre", grid_index_sync_system(wall_idx), "wall.idx");
const fov_system: System = (w) => { const player = /* query for player position */; const visible = g.line_of_sight({ from: g.world_to_cell(player.x, player.y), radius: 6, is_blocking: (c) => wall_idx.at(c) !== null, }); // toggle visibility on tiles…};Soft FOV — g.lit_area
Section titled “Soft FOV — g.lit_area”When you want gradient lighting instead of binary visible/hidden, reach for g.lit_area. Same LOS test, but returns a Map<key, intensity> with a float in [0, 1] per visible cell.
const lit = g.lit_area({ from: { x: 5, y: 4 }, radius: 6, is_blocking: (c) => blocked.has(g.key(c.x, c.y)),});
for (const [k, intensity] of lit) { // wire intensity → SpriteData.alpha for soft falloff}LightOpts.falloff defaults to linear 1 - distance/radius. Pass a custom function for quadratic, smoothstep, torch flicker, etc. Output is clamped to [0, 1]. The origin cell is always 1.0. Cells beyond the radius or behind blockers are absent from the map (= dark).
Pair with SpriteData.alpha on sprite_c to drive a soft FOV without touching tint or visibility.
Sprite-toggle gotcha
Section titled “Sprite-toggle gotcha”If you’re flipping sprite_c.visible on hundreds of tiles per FOV update, do a delta against the previous visible set — only set sprites whose visibility actually changed this tick. The full sweep allocates per-cell. See the Cookbook for the pattern.
Migration from v0.2.0
Section titled “Migration from v0.2.0”The standalone line_of_sight export is removed. Drop the grid: g slot from your opts:
// v0.2.0import { line_of_sight } from "@f0rbit/forge/grid";const visible = line_of_sight({ from, radius: 6, grid: g, is_blocking });
// v0.3.0const visible = g.line_of_sight({ from, radius: 6, is_blocking });