Skip to content

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.

  • Symmetric — opaque blockers stop the line on entry, so visibility is a strict relation rather than a half-open one.
  • CheapO(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 handlinginclude_origin: true (default) always adds from. The blocker test is not applied to from or to the target cell itself; only to intermediate steps.

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

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.

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.

The standalone line_of_sight export is removed. Drop the grid: g slot from your opts:

// v0.2.0
import { line_of_sight } from "@f0rbit/forge/grid";
const visible = line_of_sight({ from, radius: 6, grid: g, is_blocking });
// v0.3.0
const visible = g.line_of_sight({ from, radius: 6, is_blocking });