
Fog Of War Js Ts
- 71 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
fog-of-war-js-ts is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- fog-of-war-js-ts
- AI & Agent Building
- AI-coding skill
Fog Of War Js Ts by the numbers
- 71 all-time installs (skills.sh)
- +8 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,673 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill fog-of-war-js-tsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 71 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with fog-of-war-js-ts.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when fog-of-war-js-ts is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to fog-of-war-js-ts: fog-of-war-js-ts; AI & Agent Building; AI-coding skill.
Files
dot-skills Fog of War (JavaScript/TypeScript) Best Practices
Performance and correctness guide for fog of war and field-of-view systems in JS/TS games, distilled from the canonical FOV literature (Björn Bergström, Albert Ford, Adam Milazzo), Red Blob Games, rot.js, and the MDN/WebGL rendering APIs. Contains 44 rules across 8 categories, ordered by impact, to guide writing, reviewing, and refactoring visibility code.
When to Apply
Reference these guidelines when:
- Implementing field of view, line of sight, or tile visibility for a grid or continuous map
- Building or refactoring a fog-of-war display (unexplored / explored / visible layers)
- Diagnosing slow fog (recompute every frame), visual artifacts (flicker, light leaks), or memory blowups on large maps
- Scaling visibility to many units (RTS) or to large/streaming worlds
- Choosing how to store and render the visibility/explored state
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | FOV / Visibility Algorithm | CRITICAL | fov- |
| 2 | Update Scheduling & Incremental Recompute | CRITICAL | update- |
| 3 | State Representation & Data Structures | HIGH | state- |
| 4 | Rendering the Fog Layer | HIGH | render- |
| 5 | Memory & Allocation | MEDIUM-HIGH | mem- |
| 6 | Multi-Viewer & Map Scaling | MEDIUM | scale- |
| 7 | Geometry & Hot-Loop Math | MEDIUM | geo- |
| 8 | Correctness & Visual Artifacts | LOW-MEDIUM | correct- |
Quick Reference
1. FOV / Visibility Algorithm (CRITICAL)
- `fov-recursive-shadowcasting` - Use recursive shadowcasting, not ray-per-cell FOV
- `fov-symmetric-shadowcasting` - Prefer symmetric shadowcasting for consistent visibility
- `fov-octant-transforms` - Transform octants with a lookup table, not eight loops
- `fov-radius-bounded-scan` - Bound the scan to the sight radius and map edges
- `fov-single-ray-los` - Use a single line-of-sight ray for point-to-point checks
- `fov-dda-continuous` - Traverse continuous space with a DDA grid walk
- `fov-visibility-polygon` - Compute a visibility polygon for smooth 2D fog
2. Update Scheduling & Incremental Recompute (CRITICAL)
- `update-recompute-on-move` - Recompute field of view only when the viewer moves
- `update-dirty-flag` - Track a dirty flag per viewer and a map version stamp
- `update-refcount-visibility` - Count viewers per tile for incremental multi-viewer updates
- `update-delta-not-clear` - Emit visibility deltas instead of clear-all-recompute
- `update-debounce-map-edits` - Batch map edits and recompute affected viewers once
- `update-merge-explored` - Merge visible into explored as you reveal, never rebuild it
3. State Representation & Data Structures (HIGH)
- `state-typed-arrays` - Store fog state in typed arrays, not arrays of objects
- `state-flat-1d-index` - Index a flat buffer with y*width+x, not nested arrays
- `state-three-state-encoding` - Encode the three fog states as bit flags in one byte
- `state-bitset-layers` - Use a bitset for boolean visibility layers
- `state-row-major-iteration` - Iterate row-major to match the buffer's memory layout
- `state-no-string-keys` - Avoid string-keyed maps for per-tile visibility
4. Rendering the Fog Layer (HIGH)
- `render-offscreen-fog-layer` - Render fog to a separate offscreen layer
- `render-dirty-region-only` - Repaint only the dirty fog region
- `render-imagedata-not-fillrect` - Build fog as one ImageData, not per-tile fillRect
- `render-lowres-soft-upscale` - Render soft fog at tile resolution and upscale on the GPU
- `render-webgl-texsubimage` - Upload only the dirty rect of the fog texture in WebGL
- `render-fade-alpha-lerp` - Animate fog reveal by lerping alpha, not recomputing FOV
5. Memory & Allocation (MEDIUM-HIGH)
- `mem-reuse-buffers` - Allocate fog buffers once and reuse them
- `mem-clear-with-fill` - Clear the visible buffer with fill, not reallocation or a loop
- `mem-generation-stamp` - Use a generation stamp to skip the per-frame clear
- `mem-bitpack-explored` - Pack the explored layer into bits for memory and saves
- `mem-no-hot-loop-closures` - Hoist allocations out of the FOV scan loop
6. Multi-Viewer & Map Scaling (MEDIUM)
- `scale-chunk-large-maps` - Chunk large maps and keep only active chunks resident
- `scale-shared-team-visibility` - Share one refcounted visibility buffer per team
- `scale-spatial-partition-edits` - Find edit-affected viewers with a spatial index
- `scale-gameplay-vs-render-culling` - Separate gameplay visibility from on-screen render culling
- `scale-cap-recompute-budget` - Cap FOV recomputes per frame with a time budget
7. Geometry & Hot-Loop Math (MEDIUM)
- `geo-squared-distance` - Compare squared distances to avoid per-cell sqrt
- `geo-avoid-modulo-deindex` - Track x and y directly instead of modulo-deindexing
- `geo-integer-slope-shadows` - Use rational slopes to avoid floating-point drift
- `geo-avoid-trig-in-loop` - Precompute directions instead of calling trig per cell
- `geo-radius-shape-choice` - Choose the radius metric for the shape you want
8. Correctness & Visual Artifacts (LOW-MEDIUM)
- `correct-symmetric-walls` - Light wall tiles consistently to avoid flickering faces
- `correct-corner-peeking` - Handle diagonal wall corners deliberately
- `correct-permissiveness-model` - Pick one permissiveness model and apply it everywhere
- `correct-explored-not-overwritten` - Never let the visible pass overwrite the explored layer
How to Use
Read individual reference files for the full explanation and incorrect/correct code:
- Start with
fov-andupdate-for the two biggest wins: a correct algorithm and recomputing only on change. - Section definitions - Category structure, impact levels, and the execution-lifecycle ordering.
- Rule template - Template for adding new rules.
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
Fog of War (JavaScript/TypeScript)
Version 0.1.0 dot-skills May 2026
Note: This document is mainly for agents and LLMs to follow when maintaining, generating, or refactoring Fog of War (JavaScript/TypeScript) code. Humans may also find it useful,
but guidance here is optimized for automation and consistency by AI-assisted workflows.
---
Abstract
Performance and correctness guide for implementing fog of war and field of view in JavaScript/TypeScript games, designed for AI agents and LLMs. Contains 44 rules across 8 categories, ordered by impact from critical (visibility algorithm choice, update scheduling) through high (state representation, fog rendering) to incremental (hot-loop geometry, visual-artifact correctness). Each rule explains why it matters and pairs a production-realistic incorrect example with a minimal-diff correct one in TypeScript. Covers recursive and symmetric shadowcasting, single-ray and DDA line of sight, 2D visibility polygons, recompute-on-move and refcounted multi-viewer updates, typed-array/bitset/generation-stamp state, offscreen-canvas and WebGL dirty-rect rendering, chunked large-map and team-shared scaling, and a consistent visibility model that avoids flicker and light leaks.
---
Table of Contents
1. FOV / Visibility Algorithm — CRITICAL
- 1.1 Bound the Scan to the Sight Radius and Map Edges — HIGH (O(width by height) to O(radius squared))
- 1.2 Compute a Visibility Polygon for Smooth 2D Fog — MEDIUM-HIGH (O(n log n) for n wall endpoints)
- 1.3 Prefer Symmetric Shadowcasting for Consistent Visibility — CRITICAL (prevents asymmetric visibility artifacts)
- 1.4 Transform Octants With a Lookup Table, Not Eight Loops — HIGH (eliminates eight duplicated scan loops)
- 1.5 Traverse Continuous Space With a DDA Grid Walk — MEDIUM-HIGH (prevents missed thin walls)
- 1.6 Use a Single Line-of-Sight Ray for Point-to-Point Checks — HIGH (O(radius squared) to O(radius))
- 1.7 Use Recursive Shadowcasting, Not Ray-Per-Cell FOV — CRITICAL (eliminates redundant ray overlap)
2. Update Scheduling & Incremental Recompute — CRITICAL
- 2.1 Batch Map Edits and Recompute Affected Viewers Once — MEDIUM-HIGH (reduces N edits to 1 recompute)
- 2.2 Count Viewers Per Tile for Incremental Multi-Viewer Updates — CRITICAL (O(viewers) to O(1) per tile hide)
- 2.3 Emit Visibility Deltas Instead of Clear-All-Recompute — HIGH (reduces redraw to changed tiles)
- 2.4 Merge Visible Into Explored as You Reveal, Never Rebuild It — HIGH (maintains O(1) explored updates)
- 2.5 Recompute Field of View Only When the Viewer Moves — CRITICAL (prevents per-frame recompute)
- 2.6 Track a Dirty Flag Per Viewer and a Map Version Stamp — HIGH (avoids clean viewer recomputes)
3. State Representation & Data Structures — HIGH
- 3.1 Avoid String-Keyed Maps for Per-Tile Visibility — MEDIUM (eliminates per-tile string hashing)
- 3.2 Encode the Three Fog States as Bit Flags in One Byte — HIGH (reduces three layers to one byte)
- 3.3 Index a Flat Buffer With y times width plus x, Not Nested Arrays — HIGH (eliminates per-row array indirection)
- 3.4 Iterate Row-Major to Match the Buffer's Memory Layout — MEDIUM (reduces cache misses)
- 3.5 Store Fog State in Typed Arrays, Not Arrays of Objects — HIGH (eliminates per-cell object overhead)
- 3.6 Use a Bitset for Boolean Visibility Layers — MEDIUM-HIGH (reduces boolean-layer memory 8x)
4. Rendering the Fog Layer — HIGH
- 4.1 Animate Fog Reveal by Lerping Alpha, Not Recomputing FOV — MEDIUM (avoids sub-frame FOV recompute)
- 4.2 Build Fog as One ImageData, Not Per-Tile fillRect — HIGH (reduces thousands of draws to one blit)
- 4.3 Render Fog to a Separate Offscreen Layer — HIGH (avoids per-frame fog rasterisation)
- 4.4 Render Soft Fog at Tile Resolution and Upscale on the GPU — MEDIUM-HIGH (eliminates per-pixel CPU blur)
- 4.5 Repaint Only the Dirty Fog Region — HIGH (O(map) to O(changed tiles))
- 4.6 Upload Only the Dirty Rect of the Fog Texture in WebGL — MEDIUM-HIGH (reduces upload to the dirty rect)
5. Memory & Allocation — MEDIUM-HIGH
- 5.1 Allocate Fog Buffers Once and Reuse Them — MEDIUM-HIGH (prevents per-frame GC pauses)
- 5.2 Clear the Visible Buffer With fill, Not Reallocation or a Loop — MEDIUM (avoids per-frame allocation)
- 5.3 Hoist Allocations Out of the FOV Scan Loop — MEDIUM (eliminates per-cell allocation)
- 5.4 Pack the Explored Layer Into Bits for Memory and Saves — MEDIUM (reduces explored memory 8x)
- 5.5 Use a Generation Stamp to Skip the Per-Frame Clear — MEDIUM (O(n) clear to O(1))
6. Multi-Viewer & Map Scaling — MEDIUM
- 6.1 Cap FOV Recomputes Per Frame With a Time Budget — MEDIUM (maintains a fixed frame budget)
- 6.2 Chunk Large Maps and Keep Only Active Chunks Resident — MEDIUM (reduces resident memory to active chunks)
- 6.3 Find Edit-Affected Viewers With a Spatial Index — MEDIUM (O(viewers) to O(nearby viewers))
- 6.4 Separate Gameplay Visibility From On-Screen Render Culling — MEDIUM (prevents culling gameplay state)
- 6.5 Share One Refcounted Visibility Buffer Per Team — MEDIUM (O(units) to O(1) per tile query)
7. Geometry & Hot-Loop Math — MEDIUM
- 7.1 Choose the Radius Metric for the Shape You Want — MEDIUM (reduces radius test to an integer compare)
- 7.2 Compare Squared Distances to Avoid Per-Cell sqrt — MEDIUM (eliminates per-cell sqrt)
- 7.3 Precompute Directions Instead of Calling Trig Per Cell — MEDIUM (eliminates per-cell trig)
- 7.4 Track x and y Directly Instead of Modulo-Deindexing — MEDIUM (eliminates per-cell modulo)
- 7.5 Use Rational Slopes to Avoid Floating-Point Drift — MEDIUM (prevents float-drift artifacts)
8. Correctness & Visual Artifacts — LOW-MEDIUM
- 8.1 Handle Diagonal Wall Corners Deliberately — LOW-MEDIUM (prevents diagonal light leaks)
- 8.2 Light Wall Tiles Consistently to Avoid Flickering Faces — LOW-MEDIUM (prevents flickering wall faces)
- 8.3 Never Let the Visible Pass Overwrite the Explored Layer — LOW-MEDIUM (preserves remembered tiles)
- 8.4 Pick One Permissiveness Model and Apply It Everywhere — LOW-MEDIUM (prevents inconsistent visibility)
---
References
1. https://www.albertford.com/shadowcasting/ 2. https://www.roguebasin.com/index.php/FOV_using_recursive_shadowcasting 3. http://www.adammil.net/blog/v125_Roguelike_Vision_Algorithms.html 4. https://www.redblobgames.com/articles/visibility/ 5. https://www.redblobgames.com/grids/line-drawing/ 6. https://ondras.github.io/rot.js/manual/#fov 7. https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas 8. https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html
---
Source Files
This document was compiled from individual reference files. For detailed editing or extension:
| File | Description |
|---|---|
| references/_sections.md | Category definitions and impact ordering |
| assets/templates/_template.md | Template for creating new rules |
| SKILL.md | Quick reference entry point |
| metadata.json | Version and reference URLs |
{Imperative title — identical to the title above}
{1-3 sentences on WHY this matters: what goes wrong without the pattern and how the cost cascades across cells, viewers, or frames. The model generalizes from the reasoning, so explain the failure mode in concrete terms — do not just say "use X".}
Incorrect ({non-vague label of the problem}):
// Production-realistic anti-pattern, not a strawman.
// Comment the cost (per-cell allocation, per-frame recompute, cache miss, etc.).Correct ({non-vague label of the fix}):
// Minimal diff from the incorrect version.
// Comment the benefit.{Optional sections, include only what helps:}
When NOT to use this pattern:
- {Exception with the reason}
Benefits:
- {Concrete advantage}
Warning ({context}):
- {Gotcha to avoid}
Reference: [{Source title}]({https URL})
<!-- Authoring notes (delete before saving):
- The first tag MUST equal the file/category prefix (fov, update, state, render, mem, scale, geo, correct).
- Title must start with a capitalized imperative verb followed by a space (no acronym or hyphenated first word).
- Use a non-vague parenthetical after Incorrect/Correct (avoid bad/good/wrong/right/better/worse).
- Code fences must declare a letter-only language (typescript, tsx, json) at column 0.
- Keep examples type-correct under
tsc --strict. Reuse the shared model: a flat Uint8Array
fog buffer with VISIBLE/EXPLORED/OPAQUE bit flags, indexed by y*width+x. -->
{
"version": "0.1.0",
"organization": "dot-skills",
"technology": "Fog of War (JavaScript/TypeScript)",
"discipline": "distillation",
"type": "library-reference",
"date": "May 2026",
"abstract": "Performance and correctness guide for implementing fog of war and field of view in JavaScript/TypeScript games, designed for AI agents and LLMs. Contains 44 rules across 8 categories, ordered by impact from critical (visibility algorithm choice, update scheduling) through high (state representation, fog rendering) to incremental (hot-loop geometry, visual-artifact correctness). Each rule explains why it matters and pairs a production-realistic incorrect example with a minimal-diff correct one in TypeScript. Covers recursive and symmetric shadowcasting, single-ray and DDA line of sight, 2D visibility polygons, recompute-on-move and refcounted multi-viewer updates, typed-array/bitset/generation-stamp state, offscreen-canvas and WebGL dirty-rect rendering, chunked large-map and team-shared scaling, and a consistent visibility model that avoids flicker and light leaks.",
"references": [
"https://www.albertford.com/shadowcasting/",
"https://www.roguebasin.com/index.php/FOV_using_recursive_shadowcasting",
"http://www.adammil.net/blog/v125_Roguelike_Vision_Algorithms.html",
"https://www.redblobgames.com/articles/visibility/",
"https://www.redblobgames.com/grids/line-drawing/",
"https://ondras.github.io/rot.js/manual/#fov",
"https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas",
"https://webgl2fundamentals.org/webgl/lessons/webgl-data-textures.html"
],
"category": "Game Development"
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
Sections are ordered by the execution lifecycle of a fog-of-war system: schedule → compute FOV → merge → persist → diff → render → composite. Mistakes earlier in this chain cascade across every viewer and every frame.
---
1. FOV / Visibility Algorithm (fov)
Impact: CRITICAL Description: The visibility algorithm fixes the complexity and correctness of every recompute; recursive shadowcasting visits each cell once (O(cells in radius)) while a naive ray-per-perimeter-cell sweep re-walks shared cells (O(cells × radius)) and produces asymmetric artifacts.
2. Update Scheduling & Incremental Recompute (update)
Impact: CRITICAL Description: Recomputing field of view when nothing changed multiplies cost across frames and across units; gating recompute on movement and applying refcounted deltas instead of full rebuilds is the single largest real-world win.
3. State Representation & Data Structures (state)
Impact: HIGH Description: Fog state is touched on every tile access and every recompute, so the container choice dominates cache behavior; flat typed arrays and bitsets are an order of magnitude faster than boolean[][] or Map keyed by "x,y" strings.
4. Rendering the Fog Layer (render)
Impact: HIGH Description: The fog is painted on frames where it is visible, so per-frame paint cost is paid continuously; an offscreen layer with dirty-region blits and GPU-upscaled soft fog avoids redrawing thousands of tiles every frame.
5. Memory & Allocation (mem)
Impact: MEDIUM-HIGH Description: Allocating fog buffers per frame creates GC pressure that surfaces as visible stutter; reusing buffers, clearing with fill/generation stamps, and bit-packing the explored layer keep the heap flat.
6. Multi-Viewer & Map Scaling (scale)
Impact: MEDIUM Description: RTS-scale unit counts and large maps break naive per-unit, per-frame approaches; chunking, shared team visibility, spatial-partitioned edits, and capped recompute budgets keep the cost bounded as the world grows.
7. Geometry & Hot-Loop Math (geo)
Impact: MEDIUM Description: Per-cell math is multiplied across every scanned cell, so small constant-factor wins compound; squared-distance radius checks, integer slope arithmetic, and integer Bresenham avoid sqrt, float drift, and per-cell trig.
8. Correctness & Visual Artifacts (correct)
Impact: LOW-MEDIUM Description: A clean result without flicker or extra passes depends on a consistent visibility model; symmetry, deliberate corner-peeking rules, and a monotonic explored layer prevent the rework that ad-hoc fixes cause.
Handle Diagonal Wall Corners Deliberately
When two walls meet at a diagonal, a field of view that treats the shared corner as passable lets the viewer see through the one-point gap — light leaks diagonally into rooms it should not reach, and players exploit it to peek around corners. Pick a corner policy and apply it everywhere: either expand walls so a diagonal touch blocks sight, or explicitly allow corner-peeking as a design choice. The bug is leaving it to chance per algorithm path.
Incorrect (diagonal gap leaks light by accident):
// A ray slipping exactly through the corner between two walls is treated as open,
// so light leaks diagonally into the sealed room behind them.
function blocksDiagonal(grid: Grid, x: number, y: number, dx: number, dy: number): boolean {
return grid.isOpaque(x + dx, y + dy); // ignores the two flanking corner walls
}Correct (treat a diagonal pinch as blocked):
// A diagonal step is blocked unless at least one orthogonal neighbour is open.
function blocksDiagonal(grid: Grid, x: number, y: number, dx: number, dy: number): boolean {
if (grid.isOpaque(x + dx, y + dy)) return true;
const sideA = grid.isOpaque(x + dx, y);
const sideB = grid.isOpaque(x, y + dy);
return sideA && sideB; // both flanking walls present -> the corner seals the gap
}Benefits:
- Sealed rooms stay sealed; no diagonal peeking unless you choose to allow it.
- A single corner policy keeps shadowcasting and line-of-sight checks consistent.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Never Let the Visible Pass Overwrite the Explored Layer
Fog of war remembers explored terrain even after it leaves sight, so the explored layer must be monotonic — once set, never cleared. The classic bug stores a single tristate value per tile and, when clearing visibility for the new frame, resets visible tiles all the way back to "unseen", erasing the memory of everything currently in view the moment the viewer looks away. Keep explored as an independent sticky bit that the visibility clear never touches.
Incorrect (single state; clearing visible erases memory):
type FogState = 0 | 1 | 2; // 0 unseen, 1 explored, 2 visible
const state: Uint8Array = new Uint8Array(width * height);
function newFrame(): void {
for (let i = 0; i < state.length; i++) {
if (state[i] === 2) state[i] = 0; // BUG: visible -> unseen, memory lost
}
}Correct (separate sticky explored bit):
const VISIBLE = 1;
const EXPLORED = 2;
const fog = new Uint8Array(width * height);
function reveal(i: number): void {
fog[i] |= VISIBLE | EXPLORED; // explored is set once and stays set
}
function clearVisible(): void {
for (let i = 0; i < fog.length; i++) fog[i] &= ~VISIBLE; // explored bit untouched
}Benefits:
- Tiles leaving sight render as dimmed memory, not black — the expected fog-of-war look.
- Pairs with
update-merge-explored(set explored at reveal) andstate-three-state-encoding(the bit layout).
Reference: rot.js field-of-view documentation
Pick One Permissiveness Model and Apply It Everywhere
FOV algorithms differ in how generously they reveal partially-blocked tiles: restrictive (a tile is visible only if its whole face is unblocked), permissive (visible if any part is), and symmetric (visible if its center is, guaranteeing mutual sight). Mixing models across systems — display fog uses one, AI line-of-sight uses another — produces tiles the player sees but enemies cannot react to, or shots that hit targets the player cannot see. Choose one model and route every visibility query through it.
Incorrect (display and AI use different models):
function renderFog(grid: Grid, p: Viewer): void {
computePermissiveFov(grid, p); // generous: reveals partially-blocked tiles
}
function enemyCanSeePlayer(grid: Grid, e: Viewer, p: Viewer): boolean {
return restrictiveLos(grid, e, p); // strict: disagrees with what the player sees
}Correct (one shared model for all queries):
// Single source of truth: symmetric FOV (fov-symmetric-shadowcasting).
function isVisibleBetween(grid: Grid, a: Viewer, b: Viewer): boolean {
return symmetricVisible(grid, a.x, a.y, b.x, b.y); // mutual by construction
}
function renderFog(grid: Grid, p: Viewer): void { computeSymmetricFov(grid, p); }
function enemyCanSeePlayer(grid: Grid, e: Viewer, p: Viewer): boolean {
return isVisibleBetween(grid, e, p); // same rule the fog display uses
}Benefits:
- What the player sees and what enemies react to always agree — fair stealth and combat.
- Symmetric models make "A sees B" imply "B sees A", removing a whole class of bugs.
Reference: Symmetric Shadowcasting (Albert Ford)
Light Wall Tiles Consistently to Avoid Flickering Faces
Walls are opaque, so a naive field of view that only lights floor tiles leaves wall tiles dark even when the player is standing right beside them, and as the viewer moves, individual wall tiles pop in and out depending on which ray happened to clip them. Adopt one consistent rule — a wall tile is visible if any floor tile it borders that faces the viewer is visible — so wall faces light up coherently and stay lit while in view.
Incorrect (only floors get lit; walls flicker):
function reveal(grid: Grid, x: number, y: number): void {
if (grid.isOpaque(x, y)) return; // walls never lit -> dark, flickering edges
grid.setVisible(x, y);
}Correct (light a wall if a visible facing floor borders it):
function reveal(grid: Grid, x: number, y: number): void {
grid.setVisible(x, y); // floors and walls both reachable by the sweep get lit
}
// During shadowcasting, walls ARE marked visible when the scan reaches them
// (they end the slope range but are revealed first). A symmetric algorithm
// (fov-symmetric-shadowcasting) reveals walls before clipping, so a wall is lit
// exactly when a visible floor faces it — no per-frame popping.Benefits:
- Wall faces adjacent to lit floors render solidly instead of flickering.
- Symmetric reveal (
fov-symmetric-shadowcasting) gives this for free; ad-hoc fixes do not.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Traverse Continuous Space With a DDA Grid Walk
For sub-tile viewer positions or 2.5D raycasters, advancing a ray by a fixed step is a lose-lose: a large step skips walls thinner than the step (light leaks through corners) while a small step oversamples, marking the same cell many times. A DDA (digital differential analyzer) walk steps exactly to each cell boundary, so it visits every cell the ray crosses exactly once with no gaps and no repeats, regardless of direction.
Incorrect (fixed-step sampling):
// STEP too large skips thin walls; STEP too small re-marks cells. Both are wrong.
function castRay(grid: Grid, x: number, y: number, dx: number, dy: number, maxDist: number): void {
const STEP = 0.25;
for (let t = 0; t < maxDist; t += STEP) {
const tx = Math.floor(x + dx * t);
const ty = Math.floor(y + dy * t);
grid.setVisible(tx, ty);
if (grid.isOpaque(tx, ty)) return;
}
}Correct (DDA — one cell per boundary crossing):
function castRay(grid: Grid, px: number, py: number, dx: number, dy: number, maxDist: number): void {
let cx = Math.floor(px);
let cy = Math.floor(py);
const stepX = dx >= 0 ? 1 : -1;
const stepY = dy >= 0 ? 1 : -1;
const tDeltaX = dx === 0 ? Infinity : Math.abs(1 / dx);
const tDeltaY = dy === 0 ? Infinity : Math.abs(1 / dy);
let tMaxX = dx === 0 ? Infinity : (stepX > 0 ? cx + 1 - px : px - cx) * tDeltaX;
let tMaxY = dy === 0 ? Infinity : (stepY > 0 ? cy + 1 - py : py - cy) * tDeltaY;
let dist = 0;
while (dist <= maxDist) {
grid.setVisible(cx, cy);
if (grid.isOpaque(cx, cy)) return;
if (tMaxX < tMaxY) { cx += stepX; dist = tMaxX; tMaxX += tDeltaX; }
else { cy += stepY; dist = tMaxY; tMaxY += tDeltaY; }
}
}When NOT to use this pattern:
- Pure tile-based fog where the viewer always sits on a cell center — shadowcasting (
fov-recursive-shadowcasting) is faster and needs no per-ray loop. - Integer tile-to-tile yes/no checks — use the Bresenham single-ray (
fov-single-ray-los). DDA earns its keep only when the origin or direction is sub-tile (continuous), where Bresenham's integer stepping would misplace the line.
Reference: Line drawing on a grid (Red Blob Games)
Transform Octants With a Lookup Table, Not Eight Loops
Field of view is radially symmetric, so the scan logic is identical in all eight octants up to a reflection or rotation of the (row, col) axes. Copy-pasting the loop once per octant — or trying to handle the full 360° sweep in one pass — multiplies the surface area for off-by-one slope bugs eightfold, and a fix applied to one copy silently misses the others. A small multiplier table maps local octant coordinates to world coordinates so one scan function serves all eight.
Incorrect (eight near-identical loops):
function castOctant0(grid: Grid, cx: number, cy: number, r: number): void {
for (let i = 1; i <= r; i++) {
for (let dx = -i; dx <= 0; dx++) {
grid.setVisible(cx + dx, cy - i); // octant 0 mapping baked in
// ...slope bookkeeping...
}
}
}
function castOctant1(grid: Grid, cx: number, cy: number, r: number): void {
for (let i = 1; i <= r; i++) {
for (let dx = -i; dx <= 0; dx++) {
grid.setVisible(cx - i, cy + dx); // octant 1 mapping baked in
// ...same slope bookkeeping, copy-pasted...
}
}
}
// ...six more copies, each a place for a divergent bug...Correct (one scan, table-driven mapping):
const MULT: ReadonlyArray<ReadonlyArray<number>> = [
[1, 0, 0, -1, -1, 0, 0, 1],
[0, 1, -1, 0, 0, -1, 1, 0],
[0, 1, 1, 0, 0, -1, -1, 0],
[1, 0, 0, 1, -1, 0, 0, -1],
];
function toWorld(cx: number, cy: number, dx: number, dy: number, oct: number): [number, number] {
return [
cx + dx * MULT[0][oct] + dy * MULT[1][oct],
cy + dx * MULT[2][oct] + dy * MULT[3][oct],
];
}
function computeFov(grid: Grid, cx: number, cy: number, r: number): void {
grid.setVisible(cx, cy);
for (let oct = 0; oct < 8; oct++) castLight(grid, cx, cy, r, 1, 1.0, 0.0, oct);
}Benefits:
- One place to fix a slope bug; the table guarantees the other seven octants stay in sync.
- Symmetric quadrant variants reduce this further to a 4-quadrant transform (
fov-symmetric-shadowcasting).
Reference: Field of Vision using recursive shadowcasting (Björn Bergström)
Bound the Scan to the Sight Radius and Map Edges
Iterating the whole grid to test each tile's distance scales the cost with map size, not sight range, so a small torch on a large map still pays for every tile off-screen. Clamp the scan rectangle to the viewer's radius box intersected with the map bounds, and reject cells outside the circle with a squared-distance test. Cost then tracks the lit area, and the bounds clamp doubles as the cheapest possible out-of-bounds guard.
Incorrect (scans the entire map):
const r2 = radius * radius;
for (let y = 0; y < grid.height; y++) {
for (let x = 0; x < grid.width; x++) {
// Every tile on a 1000x1000 map is tested for a radius-8 torch.
if ((x - cx) ** 2 + (y - cy) ** 2 <= r2 && hasLineOfSight(grid, cx, cy, x, y)) {
grid.setVisible(x, y);
}
}
}Correct (clamped bounding box):
const r2 = radius * radius;
const x0 = Math.max(0, cx - radius);
const x1 = Math.min(grid.width - 1, cx + radius);
const y0 = Math.max(0, cy - radius);
const y1 = Math.min(grid.height - 1, cy + radius);
for (let y = y0; y <= y1; y++) {
const dy = y - cy;
for (let x = x0; x <= x1; x++) {
const dx = x - cx;
if (dx * dx + dy * dy <= r2 && hasLineOfSight(grid, cx, cy, x, y)) {
grid.setVisible(x, y);
}
}
}When NOT to use this pattern:
- Prefer shadowcasting (
fov-recursive-shadowcasting) over per-cellhasLineOfSight; the bounded box still applies, but shadowcasting already restricts itself to the radius via its slope range anddx*dx + dy*dy <= r2test.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Use Recursive Shadowcasting, Not Ray-Per-Cell FOV
Casting a separate ray to every perimeter cell re-walks the cells near the viewer once per ray, so the area close to the origin is recomputed dozens of times, and the angular gaps between adjacent rays let light leak past thin walls. Recursive shadowcasting sweeps each octant once, narrowing the visible slope range as it meets walls, so every cell is visited a single time and occlusion is exact.
Incorrect (ray-per-perimeter-cell):
interface Grid {
width: number;
height: number;
isOpaque(x: number, y: number): boolean;
setVisible(x: number, y: number): void;
}
// One ray per degree. Cells near the origin are walked by hundreds of
// overlapping rays, and gaps between rays leak light past thin walls.
function computeFov(grid: Grid, cx: number, cy: number, radius: number): void {
for (let a = 0; a < 360; a++) {
const dx = Math.cos((a * Math.PI) / 180);
const dy = Math.sin((a * Math.PI) / 180);
let x = cx + 0.5;
let y = cy + 0.5;
for (let step = 0; step < radius; step++) {
const tx = Math.floor(x);
const ty = Math.floor(y);
grid.setVisible(tx, ty);
if (grid.isOpaque(tx, ty)) break;
x += dx;
y += dy;
}
}
}Correct (octant sweep, each cell visited once):
// [transformX, transformY] multipliers map local octant coords to world coords.
const MULT: ReadonlyArray<ReadonlyArray<number>> = [
[1, 0, 0, -1, -1, 0, 0, 1],
[0, 1, -1, 0, 0, -1, 1, 0],
[0, 1, 1, 0, 0, -1, -1, 0],
[1, 0, 0, 1, -1, 0, 0, -1],
];
function castLight(
grid: Grid, cx: number, cy: number, radius: number,
row: number, start: number, end: number, oct: number,
): void {
if (start < end) return;
const r2 = radius * radius;
let newStart = 0;
for (let i = row; i <= radius; i++) {
const dy = -i;
let blocked = false;
for (let dx = -i; dx <= 0; dx++) {
const lSlope = (dx - 0.5) / (dy + 0.5);
const rSlope = (dx + 0.5) / (dy - 0.5);
if (start < rSlope) continue;
if (end > lSlope) break;
const mx = cx + dx * MULT[0][oct] + dy * MULT[1][oct];
const my = cy + dx * MULT[2][oct] + dy * MULT[3][oct];
if (dx * dx + dy * dy <= r2) grid.setVisible(mx, my);
if (blocked) {
if (grid.isOpaque(mx, my)) { newStart = rSlope; continue; }
blocked = false;
start = newStart;
} else if (grid.isOpaque(mx, my) && i < radius) {
blocked = true;
castLight(grid, cx, cy, radius, i + 1, start, lSlope, oct); // recurse past the wall
newStart = rSlope;
}
}
if (blocked) break;
}
}
function computeFov(grid: Grid, cx: number, cy: number, radius: number): void {
grid.setVisible(cx, cy);
for (let oct = 0; oct < 8; oct++) castLight(grid, cx, cy, radius, 1, 1.0, 0.0, oct);
}When NOT to use this pattern:
- Single point-to-point checks ("can the guard see the player?") — walk one line instead (
fov-single-ray-los). - Smooth vector lighting on non-grid worlds — build a visibility polygon (
fov-visibility-polygon).
Reference: Field of Vision using recursive shadowcasting (Björn Bergström)
Use a Single Line-of-Sight Ray for Point-to-Point Checks
Computing a full field of view to answer one yes/no question — "can this guard see the player?" — pays the entire radius-squared sweep when a single tile-to-tile answer is all you need. Walk one Bresenham line from source to target and stop at the first opaque tile: O(radius) instead of O(radius²). Use this for AI perception, line-of-fire checks, and trigger volumes, where you query a known target rather than reveal an area.
Incorrect (full FOV sweep for one query):
function canSee(grid: Grid, gx: number, gy: number, px: number, py: number): boolean {
const seen = new Set<number>();
const probe: Grid = {
width: grid.width, height: grid.height,
isOpaque: (x, y) => grid.isOpaque(x, y),
setVisible: (x, y) => seen.add(y * grid.width + x), // builds the whole radius
};
computeFov(probe, gx, gy, 12);
return seen.has(py * grid.width + px);
}Correct (one ray, early exit at the first wall):
function canSee(grid: Grid, x0: number, y0: number, x1: number, y1: number): boolean {
const dx = Math.abs(x1 - x0);
const dy = -Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
let x = x0;
let y = y0;
for (;;) {
if (x === x1 && y === y1) return true;
if ((x !== x0 || y !== y0) && grid.isOpaque(x, y)) return false;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x += sx; }
if (e2 <= dx) { err += dx; y += sy; }
}
}When NOT to use this pattern:
- Revealing an area for fog display — use shadowcasting (
fov-recursive-shadowcasting); N single rays to N targets is slower than one octant sweep.
Reference: Line drawing on a grid (Red Blob Games)
Prefer Symmetric Shadowcasting for Consistent Visibility
Classic recursive shadowcasting is not symmetric: a tile A can be lit from the origin while the origin is not lit from A. In gameplay this means a monster you cannot see can see you, and walls pop in and out as the viewer steps sideways. Albert Ford's symmetric shadowcasting reveals a floor tile only when its center lies inside the scanned slope range, which makes visibility mutual, and its row-by-row structure has fewer slope edge cases than the corner-based version.
Incorrect (corner test — asymmetric):
interface FovWorld {
isWall(x: number, y: number): boolean;
reveal(x: number, y: number): void;
}
// A tile is lit if ANY corner falls in range, so A sees B but B may not see A.
function shouldReveal(depth: number, col: number, start: number, end: number): boolean {
return col - 0.5 <= depth * end && col + 0.5 >= depth * start;
}Correct (center test — symmetric):
interface Quadrant {
transform(depth: number, col: number): [number, number];
}
const slope = (depth: number, col: number): number => (2 * col - 1) / (2 * depth);
const roundUp = (n: number): number => Math.floor(n + 0.5);
const roundDown = (n: number): number => Math.ceil(n - 0.5);
function scan(
world: FovWorld, q: Quadrant, depth: number,
startSlope: number, endSlope: number, maxDepth: number,
): void {
if (depth > maxDepth) return;
const minCol = roundUp(depth * startSlope);
const maxCol = roundDown(depth * endSlope);
let prevWall: boolean | null = null;
let start = startSlope;
for (let col = minCol; col <= maxCol; col++) {
const [x, y] = q.transform(depth, col);
const wall = world.isWall(x, y);
// Reveal walls always; reveal a floor only if its centre is within range.
if (wall || (col >= depth * start && col <= depth * endSlope)) world.reveal(x, y);
if (prevWall === true && !wall) start = slope(depth, col);
if (prevWall === false && wall) {
scan(world, q, depth + 1, start, slope(depth, col), maxDepth);
}
prevWall = wall;
}
if (prevWall === false) scan(world, q, depth + 1, start, endSlope, maxDepth);
}Benefits:
- Mutual visibility — fair stealth and consistent reveal across viewers.
- Cleaner wall faces with no flicker as the viewer moves.
Reference: Symmetric Shadowcasting (Albert Ford)
Compute a Visibility Polygon for Smooth 2D Fog
Tile-based shadowcasting produces blocky edges that look wrong for smooth vector lighting or non-grid worlds built from polygon walls. Casting a ray through every screen pixel to find shadows is O(pixels × walls) and resolution-dependent. An angular sweep instead casts one ray at each wall endpoint, sorts the hits by angle, and stitches them into the exact visibility polygon in O(n log n) for n endpoints — resolution-independent and far cheaper.
Incorrect (per-pixel raycast):
interface Segment { ax: number; ay: number; bx: number; by: number; }
// O(pixels x walls): a ray for every pixel on screen, every frame.
function buildShadowMask(px: number, py: number, walls: Segment[], w: number, h: number): void {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
if (rayHitsAnyWall(px, py, x, y, walls)) markShadowed(x, y);
}
}
}Correct (angular endpoint sweep):
interface Pt { x: number; y: number; }
// One ray per endpoint (plus a nudge each side of corners), sorted by angle.
function visibilityPolygon(px: number, py: number, walls: Segment[]): Pt[] {
const angles: number[] = [];
for (const s of walls) {
const a1 = Math.atan2(s.ay - py, s.ax - px);
const a2 = Math.atan2(s.by - py, s.bx - px);
angles.push(a1 - 1e-5, a1, a1 + 1e-5, a2 - 1e-5, a2, a2 + 1e-5);
}
angles.sort((m, n) => m - n);
const poly: Pt[] = [];
for (const a of angles) {
const hit = nearestHit(px, py, Math.cos(a), Math.sin(a), walls);
if (hit) poly.push(hit); // closest intersection along this ray
}
return poly; // render as a light/visibility mask; outside the polygon is fog
}Common use cases:
- 2D stealth/lighting where shadows must follow arbitrary wall angles.
- Top-down games with polygon (not tile) collision geometry.
Reference: 2D Visibility (Red Blob Games)
Track x and y Directly Instead of Modulo-Deindexing
Recovering tile coordinates from a flat index with i % width and Math.floor(i / width) puts an integer division and a modulo on the hot path for every cell — and integer division is one of the slowest arithmetic operations. When you iterate the grid, keep x and y as loop variables (or carry a running row base), so the index is a single addition and the coordinates are already in hand.
Incorrect (deindex every iteration):
for (let i = 0; i < fog.length; i++) {
const x = i % width; // modulo per cell
const y = (i / width) | 0; // integer division per cell
if (isVisible(fog[i])) drawTile(x, y);
}Correct (iterate coordinates, derive index by addition):
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++, i++) {
// x, y already known; i advances by one add — no modulo, no division.
if (isVisible(fog[i])) drawTile(x, y);
}
}When NOT to use this pattern:
- Iterating a sparse list of changed indices (from
update-delta-not-clear) where you visit scattered tiles, not the full grid — there a single% widthper changed tile is fine because the set is small.
Reference: MDN — Bitwise OR (the `| 0` integer-truncation idiom used above)
Precompute Directions Instead of Calling Trig Per Cell
Cone or directional fields of view often check each cell's angle against the facing direction, and calling Math.atan2, sin, or cos per cell loads a transcendental into the innermost loop. Trig of a constant facing belongs outside the loop: precompute the facing vector once and test cells with a dot product, or compare against precomputed cone-edge slopes — both are plain multiplies and adds.
Incorrect (atan2 per cell):
function inCone(cx: number, cy: number, facing: number, halfAngle: number, x: number, y: number): boolean {
const a = Math.atan2(y - cy, x - cx); // atan2 per cell
let diff = Math.abs(a - facing);
if (diff > Math.PI) diff = 2 * Math.PI - diff;
return diff <= halfAngle;
}Correct (precompute facing vector, use a dot product):
// Computed once per viewer, not per cell.
const fx = Math.cos(facing);
const fy = Math.sin(facing);
const cosHalf = Math.cos(halfAngle);
function inCone(cx: number, cy: number, x: number, y: number): boolean {
const dx = x - cx;
const dy = y - cy;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return (dx * fx + dy * fy) / len >= cosHalf; // dot product vs precomputed threshold
}Benefits:
- The per-cell cost drops to a dot product and one normalisation; the trig runs once per viewer.
- Combine with
geo-squared-distanceto fuse the cone test with the radius test cheaply.
Reference: MDN — Math.atan2
Use Rational Slopes to Avoid Floating-Point Drift
Shadowcasting compares cell-edge slopes to decide what a wall occludes. Computing those slopes in floating point accumulates rounding error, so at larger radii the lit/shadowed boundary drifts by a fraction of a cell — visible as edges that flicker or shift asymmetrically as the viewer steps sideways. Represent each slope as an exact rational [numerator, denominator] of integers and compare by cross-multiplication, so the comparison is exact and the boundary is stable.
Incorrect (floating-point slopes drift):
const slope = (col: number, depth: number): number => (2 * col - 1) / (2 * depth);
// Float rounding makes the boundary wobble at large depth.
if (slope(colA, depthA) < slope(colB, depthB)) extendShadow();Correct (exact rational comparison):
type Slope = readonly [num: number, den: number];
// Edge slope as integers: numerator = 2*col - 1, denominator = 2*depth.
const edge = (col: number, depth: number): Slope => [2 * col - 1, 2 * depth];
// a/b < c/d <=> a*d < c*b (denominators are positive here).
const lessThan = (a: Slope, b: Slope): boolean => a[0] * b[1] < b[0] * a[1];
if (lessThan(edge(colA, depthA), edge(colB, depthB))) extendShadow();Benefits:
- The lit/shadow boundary is identical at every radius and viewer position — no flicker.
- Integer comparisons are also faster than float division on the hot path.
Reference: Symmetric Shadowcasting (Albert Ford)
Choose the Radius Metric for the Shape You Want
The distance metric defines both the lit shape and its cost. Squared-Euclidean (dx² + dy²) gives a circle, Chebyshev (max(|dx|,|dy|)) a square, and Manhattan (|dx| + |dy|) a diamond — each a cheap integer test. Computing a floating Euclidean distance and rounding to approximate one of these is both slower and the wrong shape. Decide which silhouette the game wants and use that metric's native integer form directly.
Incorrect (float Euclidean, then round toward a square):
// Wanted a square sight range, but this is a rounded circle — and uses sqrt.
function inRange(dx: number, dy: number, r: number): boolean {
return Math.round(Math.sqrt(dx * dx + dy * dy)) <= r;
}Correct (pick the metric that is the intended shape):
const r2 = r * r;
const circle = (dx: number, dy: number): boolean => dx * dx + dy * dy <= r2;
const square = (dx: number, dy: number): boolean => Math.max(Math.abs(dx), Math.abs(dy)) <= r;
const diamond = (dx: number, dy: number): boolean => Math.abs(dx) + Math.abs(dy) <= r;
// Choose ONE deliberately, e.g. Chebyshev for a torch that fills a square room:
const inRange = square;Benefits:
- The lit area matches the intended silhouette exactly, with no rounding artifacts.
- Every metric reduces to integer compares — no
sqrt, noround(seegeo-squared-distance).
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Compare Squared Distances to Avoid Per-Cell sqrt
The radius test runs once per cell in the field-of-view sweep, so calling Math.sqrt (or Math.hypot) to get a true distance there pays for a relatively expensive operation thousands of times per recompute, purely to compare against a constant. Since distance is monotonic in distance-squared, compare dx*dx + dy*dy against r*r instead — same result, integer multiplies only, no transcendental call.
Incorrect (sqrt per cell):
function inRadius(dx: number, dy: number, r: number): boolean {
return Math.sqrt(dx * dx + dy * dy) <= r; // sqrt per cell, every recompute
}Correct (compare squared values):
function inRadius(dx: number, dy: number, r2: number): boolean {
return dx * dx + dy * dy <= r2; // pass r*r once; integer multiplies only
}
const r2 = radius * radius; // computed once before the sweepBenefits:
- Removes a transcendental call from the innermost loop on integer-grid inputs.
- Precomputing
r*ronce hoists even the squaring of the radius out of the loop.
Reference: MDN — Math.hypot
Pack the Explored Layer Into Bits for Memory and Saves
The explored layer is a pure boolean per tile (seen or not), so storing it as a Uint8Array wastes seven of every eight bits — and for a large or persistent world, that layer is also what you serialize into save files. Pack it into a Uint32Array bitset: 8× less memory resident, and the save blob shrinks proportionally (and compresses further, since explored regions are contiguous runs of set bits).
Incorrect (one byte per explored bit, saved verbatim):
const explored = new Uint8Array(width * height); // 1 byte per tile
function save(): Uint8Array { return explored.slice(); } // 8x larger than neededCorrect (packed bitset, compact save):
const explored = new Uint32Array(Math.ceil((width * height) / 32));
const markExplored = (i: number): void => { explored[i >>> 5] |= 1 << (i & 31); };
const wasExplored = (i: number): boolean => (explored[i >>> 5] & (1 << (i & 31))) !== 0;
function save(): Uint8Array {
return new Uint8Array(explored.buffer.slice(0)); // 1 bit per tile on disk
}Benefits:
- Resident memory and save size drop 8×; run-length or gzip compression shrinks it further.
- The persistent layer stays separate from the transient visible layer, which need not be saved.
Reference: MDN — Uint32Array
Clear the Visible Buffer With fill, Not Reallocation or a Loop
Resetting the transient visible layer each recompute by allocating a new array creates garbage (mem-reuse-buffers), and resetting it with a hand-written element loop is slower than the engine's intrinsic. TypedArray.prototype.fill(0) is a single optimised memset over contiguous memory — the fastest and allocation-free way to wipe the frame's visibility before the next sweep writes into it.
Incorrect (reallocate or loop):
// Reallocates — garbage every frame.
state.visible = new Uint8Array(width * height);
// Or loops element by element — slower than the intrinsic memset.
for (let i = 0; i < state.visible.length; i++) state.visible[i] = 0;Correct (single intrinsic clear):
state.visible.fill(0); // contiguous memset, no allocation
// Clear only the box that could have been touched, to skip untouched memory:
state.visible.fill(0, rowStart, rowEnd); // fill supports start/end boundsWhen NOT to use this pattern:
- On very large maps where the lit area is tiny relative to the buffer, a generation stamp (
mem-generation-stamp) avoids the clear entirely instead of memset-ing the whole buffer.
Reference: MDN — TypedArray.prototype.fill
Use a Generation Stamp to Skip the Per-Frame Clear
On a large map where each recompute lights only a small disc, even fill(0) over the whole visible buffer is wasted work — you clear a million tiles to light a few hundred. Store a per-tile frame stamp instead of a boolean: a tile is visible when its stamp equals the current frame counter. Marking a tile visible writes the counter; the "clear" is just incrementing the counter, turning an O(n) wipe into O(1).
Incorrect (clear the whole buffer every recompute):
function recompute(state: FogState, grid: Grid, cx: number, cy: number, r: number): void {
state.visible.fill(0); // touches every tile even if only 300 become visible
castFov(grid, state.visible, cx, cy, r);
}Correct (generation stamp — O(1) reset):
class StampedFog {
readonly stamp: Uint32Array;
private frame = 0;
constructor(size: number) { this.stamp = new Uint32Array(size); }
recompute(grid: Grid, cx: number, cy: number, r: number): void {
this.frame++; // the entire "clear" — no buffer wipe
castFov(grid, (i: number) => { this.stamp[i] = this.frame; }, cx, cy, r);
}
isVisible(i: number): boolean { return this.stamp[i] === this.frame; }
}Warning (counter wraparound):
Uint32Arraystamps wrap after ~4 billion frames (years of play). If you reset or reuse the structure,fill(0)once and restart the counter to avoid a stale stamp matching frame 0.
Reference: MDN — Uint32Array
Hoist Allocations Out of the FOV Scan Loop
The field-of-view scan runs its body once per visited cell — thousands of times per recompute — so any object, array, or closure created inside it multiplies into thousands of short-lived allocations and steady GC pressure. Allocate scratch structures and callbacks once, outside the loop (or pass primitives), so the hot path allocates nothing and the JIT can keep it in registers.
Incorrect (allocates per cell):
function castFov(grid: Grid, cx: number, cy: number, r: number): void {
for (let i = 1; i <= r; i++) {
for (let dx = -i; dx <= 0; dx++) {
const cell = { x: cx + dx, y: cy - i }; // new object per cell
const handlers = [() => mark(cell)]; // new array + closure per cell
handlers.forEach((h) => h());
}
}
}Correct (no allocation in the loop):
function castFov(grid: Grid, cx: number, cy: number, r: number, mark: (i: number) => void): void {
for (let i = 1; i <= r; i++) {
const rowY = cy - i;
for (let dx = -i; dx <= 0; dx++) {
mark((rowY) * grid.width + (cx + dx)); // pass an integer; allocate nothing
}
}
}Benefits:
- The recompute produces zero garbage, so movement no longer triggers GC spikes.
- Passing a plain index instead of a coordinate object avoids boxing and pointer chasing.
Reference: MDN — Memory management
Allocate Fog Buffers Once and Reuse Them
Allocating a fresh Uint8Array for the visibility buffer on every recompute hands the garbage collector a large short-lived object each frame; the resulting GC cycles surface as periodic frame-time spikes — visible stutter exactly when the player is moving and triggering recomputes. Allocate the buffers once at map load and reuse them; clearing in place (mem-clear-with-fill) costs a fraction of an allocation and produces no garbage.
Incorrect (allocate per recompute):
function computeVisibility(grid: Grid, cx: number, cy: number, r: number): Uint8Array {
const visible = new Uint8Array(grid.width * grid.height); // garbage every move
castFov(grid, visible, cx, cy, r);
return visible;
}Correct (reuse a preallocated buffer):
class FogState {
readonly visible: Uint8Array;
readonly explored: Uint8Array;
constructor(readonly width: number, readonly height: number) {
this.visible = new Uint8Array(width * height); // allocated once
this.explored = new Uint8Array(width * height);
}
recompute(grid: Grid, cx: number, cy: number, r: number): void {
this.visible.fill(0); // reuse, no allocation
castFov(grid, this.visible, cx, cy, r);
}
}Benefits:
- Steady-state movement allocates nothing, so there are no GC pauses tied to recomputes.
- A single owning object keeps the visible/explored buffers in sync and cache-adjacent.
Reference: MDN — Memory management
Repaint Only the Dirty Fog Region
Even on its own layer, re-rasterising the whole fog when one tile changes wastes the entire canvas. Repaint only the tiles that changed — or the bounding rectangle that encloses them. Fed by visibility deltas (update-delta-not-clear), a one-tile move repaints a handful of tiles instead of the full map, which is the difference between a constant per-frame cost and one that scales with movement.
Incorrect (clear and redraw the whole layer):
function repaintFog(ctx: CanvasRenderingContext2D): void {
ctx.clearRect(0, 0, width * TILE, height * TILE); // wipes everything
for (let i = 0; i < fog.length; i++) drawTile(ctx, i); // redraws every tile
}Correct (repaint only changed tiles):
function repaintChanged(ctx: CanvasRenderingContext2D, changed: number[]): void {
for (const i of changed) {
const x = (i % width) * TILE;
const y = Math.floor(i / width) * TILE;
ctx.clearRect(x, y, TILE, TILE); // clear just this tile's cell
drawTile(ctx, i); // repaint just this tile
}
}Benefits:
- Render work tracks the visibility delta, not the sight radius or map size.
- For many scattered changes, clear the single enclosing bounding box once, then redraw inside it.
Reference: MDN — CanvasRenderingContext2D.clearRect
Animate Fog Reveal by Lerping Alpha, Not Recomputing FOV
Making fog fade in and out smoothly by recomputing field of view at sub-frame granularity (a growing radius, fractional steps) multiplies the most expensive operation by the animation duration. Recompute visibility once, on the discrete tile change, then animate each tile's fog alpha toward its target in the renderer. The field of view is computed a single time while the visuals interpolate for free every frame.
Incorrect (recompute FOV per frame for the fade):
// Re-sweeps the whole FOV every frame just to grow the lit radius smoothly.
function fadeIn(state: GameState, t: number): void {
const r = state.player.sight * easeOut(t); // fractional radius
state.visible.fill(0);
computeFov(state.grid, state.player.x, state.player.y, Math.ceil(r));
renderFog(state);
}Correct (FOV once; renderer eases alpha toward target):
// curAlpha is the displayed opacity; fog holds the discrete target state.
function animateFog(curAlpha: Float32Array, fog: Uint8Array, dt: number): void {
const rate = Math.min(1, dt * 6); // ease speed, frame-rate independent
for (let i = 0; i < curAlpha.length; i++) {
const target = fog[i] & VISIBLE ? 0 : fog[i] & EXPLORED ? 0.55 : 1;
curAlpha[i] += (target - curAlpha[i]) * rate; // smooth approach, no FOV work
}
}Benefits:
- One FOV recompute per actual visibility change; the fade is pure interpolation.
- Easing in a shader (sampling the fog texture) moves even the per-tile lerp off the CPU.
Reference: MDN — requestAnimationFrame
Build Fog as One ImageData, Not Per-Tile fillRect
A separate fillStyle assignment plus fillRect per tile issues thousands of individual draw operations per repaint, each carrying state-change and path-setup overhead, when fog is really just a per-tile alpha grid. Write the fog directly into a single ImageData (one pixel per tile) and blit it once with putImageData, then scale that small bitmap up to tile size. One upload replaces the whole nested fill loop.
Incorrect (one fillRect per tile):
function paintFog(ctx: CanvasRenderingContext2D): void {
for (let i = 0; i < fog.length; i++) {
const f = fog[i];
ctx.fillStyle = f & VISIBLE ? "rgba(0,0,0,0)" : f & EXPLORED ? "rgba(0,0,0,0.55)" : "#000";
ctx.fillRect((i % width) * TILE, Math.floor(i / width) * TILE, TILE, TILE);
}
}Correct (one pixel per tile in an ImageData, blit once):
const tileFog = new OffscreenCanvas(width, height); // one texel per tile
const tileCtx = tileFog.getContext("2d")!;
const img = tileCtx.createImageData(width, height);
const px = img.data; // Uint8ClampedArray: 4 bytes per tile, RGB stay 0 (black)
function paintFog(main: CanvasRenderingContext2D): void {
for (let i = 0; i < fog.length; i++) {
const f = fog[i];
px[i * 4 + 3] = f & VISIBLE ? 0 : f & EXPLORED ? 140 : 255; // only alpha varies
}
tileCtx.putImageData(img, 0, 0); // single upload replaces width*height fillRects
main.drawImage(tileFog, 0, 0, width, height, 0, 0, width * TILE, height * TILE);
}createImageData zero-fills, so the R, G, B bytes stay 0 (black fog) and only alpha changes per frame. For a tinted fog, write the RGB bytes once at setup and keep mutating only alpha in the loop.
Benefits:
- One
putImageDataplus onedrawImageinstead ofwidth × heightfill calls. - The small per-tile bitmap upscales cheaply and enables soft edges (
render-lowres-soft-upscale).
Reference: MDN — CanvasRenderingContext2D.putImageData
Render Soft Fog at Tile Resolution and Upscale on the GPU
Soft, feathered fog edges computed per screen pixel on the CPU cost millions of operations per frame for a blur the GPU does for free. Render the fog at tile resolution — one texel per tile — into a small buffer, then let the hardware bilinear-filter it up to screen size when you draw it. The CPU writes one value per tile; the smoothing happens during the scaled blit.
Incorrect (per-pixel CPU blur of a full-res fog bitmap):
// Box-blur every screen pixel of the full-resolution fog every frame.
function blurFog(src: Uint8ClampedArray, dst: Uint8ClampedArray, w: number, h: number): void {
for (let y = 1; y < h - 1; y++) {
for (let x = 1; x < w - 1; x++) {
let sum = 0;
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) sum += src[((y + oy) * w + (x + ox)) * 4 + 3];
}
dst[(y * w + x) * 4 + 3] = sum / 9; // millions of ops per frame
}
}
}Correct (tile-res fog, GPU bilinear upscale):
const tileFog = new OffscreenCanvas(width, height); // one texel per tile
// ...write one alpha per tile into tileFog via ImageData (render-imagedata-not-fillrect)...
function compositeFog(main: CanvasRenderingContext2D): void {
main.imageSmoothingEnabled = true; // bilinear filtering during upscale = soft edges
main.drawImage(tileFog, 0, 0, width, height, 0, 0, width * TILE, height * TILE);
}Benefits:
- The blur is hardware-interpolated for free; CPU work drops to one write per tile.
- The small fog buffer also uploads faster to a WebGL texture (
render-webgl-texsubimage).
Reference: MDN — CanvasRenderingContext2D.imageSmoothingEnabled
Render Fog to a Separate Offscreen Layer
Painting fog directly onto the main canvas forces you to repaint the map underneath it every frame, because 2D canvas drawing is destructive. Render the fog once to its own offscreen canvas and composite that bitmap over the map; on frames where only the camera or sprites move, you reuse the cached fog bitmap and skip regenerating it entirely. The fog is regenerated only when visibility actually changes.
Incorrect (re-rasterise fog into the main canvas each frame):
const TILE = 16;
function frame(ctx: CanvasRenderingContext2D): void {
drawMap(ctx);
// Rebuilds the entire fog from the buffer on every animation frame.
for (let i = 0; i < fog.length; i++) {
ctx.fillStyle = fogColorFor(fog[i]);
ctx.fillRect((i % width) * TILE, Math.floor(i / width) * TILE, TILE, TILE);
}
requestAnimationFrame(() => frame(ctx));
}Correct (cached offscreen fog layer):
const TILE = 16;
const fogLayer = new OffscreenCanvas(width * TILE, height * TILE);
const fogCtx = fogLayer.getContext("2d")!;
function onVisibilityChanged(changed: number[]): void {
paintFog(fogCtx, changed); // regenerate fog only when it changes
}
function frame(main: CanvasRenderingContext2D): void {
drawMap(main); // map repaints for camera/animation
main.drawImage(fogLayer, 0, 0); // composite the cached fog bitmap
requestAnimationFrame(() => frame(main));
}Benefits:
- A stationary scene composites one cached bitmap instead of rasterising thousands of tiles.
- Separating layers lets fog and map redraw at independent cadences.
Reference: MDN — OffscreenCanvas
Upload Only the Dirty Rect of the Fog Texture in WebGL
In a WebGL renderer, re-uploading the entire fog texture every frame with texImage2D transfers the whole buffer across the CPU-GPU bus even when only a few tiles changed. Keep the fog as a single-channel (R8) texture and push just the changed sub-rectangle with texSubImage2D, using UNPACK_ROW_LENGTH so the source rows line up with the full buffer. Sample the texture in the fragment shader, where blending and upscaling cost nothing extra.
Incorrect (full texture upload every frame):
function uploadFog(gl: WebGL2RenderingContext, fog: Uint8Array): void {
gl.bindTexture(gl.TEXTURE_2D, fogTex);
// Re-sends the whole width*height buffer even if one tile changed.
gl.texImage2D(gl.TEXTURE_2D, 0, gl.R8, width, height, 0, gl.RED, gl.UNSIGNED_BYTE, fog);
}Correct (upload only the changed sub-rectangle):
interface DirtyBox { x: number; y: number; w: number; h: number; }
function uploadDirtyFog(gl: WebGL2RenderingContext, fog: Uint8Array, box: DirtyBox): void {
gl.bindTexture(gl.TEXTURE_2D, fogTex);
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, width); // source stride = full buffer width
gl.texSubImage2D(
gl.TEXTURE_2D, 0, box.x, box.y, box.w, box.h,
gl.RED, gl.UNSIGNED_BYTE, fog, box.y * width + box.x, // offset to box origin
);
gl.pixelStorei(gl.UNPACK_ROW_LENGTH, 0); // reset for other uploads
}Benefits:
- Bus traffic scales with the dirty rectangle, not the whole map.
- Sampling the fog texture in-shader gives free bilinear softness and per-pixel blending.
Reference: WebGL2 — texSubImage2D and pixel store parameters
Cap FOV Recomputes Per Frame With a Time Budget
When many units move on the same frame — a whole squad marching — recomputing every dirty unit's field of view at once produces a single huge frame spike and a dropped frame. Spread the work: process dirty viewers from a queue until a per-frame budget (a unit count or a millisecond cap) is hit, then resume next frame. Visibility lags by a frame or two for distant units, which is imperceptible, while the frame rate stays smooth.
Incorrect (recompute all dirty units in one frame):
function update(world: World): void {
for (const v of world.viewers) {
if (v.dirty) { recomputeFov(world, v); v.dirty = false; } // 200 sweeps = dropped frame
}
}Correct (budgeted queue, spread across frames):
const MAX_MS = 2; // FOV time budget per frame
function update(world: World, queue: Viewer[]): void {
const deadline = performance.now() + MAX_MS;
while (queue.length > 0 && performance.now() < deadline) {
const v = queue.shift()!;
recomputeFov(world, v);
v.dirty = false;
}
// Remaining dirty viewers stay queued for the next frame.
}When NOT to use this pattern:
- The local player's own field of view — recompute it immediately so the player never sees stale fog around themselves; budget only the secondary viewers.
Reference: MDN — performance.now
Chunk Large Maps and Keep Only Active Chunks Resident
A full per-tile fog buffer for a very large or streaming world (tens of thousands of tiles per side, or an open world) does not fit comfortably in memory, and most of it is nowhere near any viewer. Divide the map into fixed-size chunks and keep the visible/explored buffers only for chunks near active viewers; persist distant chunks' explored layer (bit-packed, mem-bitpack-explored) to storage and evict their buffers.
Incorrect (one buffer for the entire world):
// A 50,000 x 50,000 world = 2.5 billion tiles — multiple GB of fog buffers.
const visible = new Uint8Array(WORLD_W * WORLD_H);
const explored = new Uint8Array(WORLD_W * WORLD_H);Correct (resident set of active chunks):
const CHUNK = 64; // tiles per side
interface Chunk { visible: Uint8Array; explored: Uint32Array; }
const resident = new Map<number, Chunk>();
function chunkKey(cx: number, cy: number): number { return cy * chunksWide + cx; }
function ensureResident(cx: number, cy: number): Chunk {
const key = chunkKey(cx, cy);
let c = resident.get(key);
if (!c) {
c = { visible: new Uint8Array(CHUNK * CHUNK), explored: loadExplored(key) };
resident.set(key, c);
}
return c;
}
function evictFarChunks(viewerChunks: Set<number>): void {
for (const [key, c] of resident) {
if (!viewerChunks.has(key)) { persistExplored(key, c.explored); resident.delete(key); }
}
}Benefits:
- Memory scales with the active area, not the world size.
- Explored memory persists per chunk, so revisiting a region restores its remembered state.
Reference: MDN — Memory management
Separate Gameplay Visibility From On-Screen Render Culling
It is tempting to skip field-of-view work for units off-screen, but gameplay visibility (does the enemy detect my scout? does this tile reveal on the minimap?) must stay correct whether or not the camera is looking. Conflating the two causes bugs where an off-screen unit "forgets" what it sees, or fog state desyncs when the camera pans back. Always compute gameplay visibility; cull only the rendering of fog tiles outside the viewport.
Incorrect (skip FOV for off-screen units):
function update(world: World, camera: Rect): void {
for (const u of world.units) {
if (!inViewport(u, camera)) continue; // BUG: off-screen units stop seeing
recomputeFov(world, u);
}
}Correct (compute everywhere, render only what's on screen):
function update(world: World): void {
for (const u of world.units) recomputeFov(world, u); // gameplay: always correct
}
function renderFog(ctx: CanvasRenderingContext2D, world: World, camera: Rect): void {
const [x0, y0, x1, y1] = tileBounds(camera);
for (let y = y0; y <= y1; y++) { // render: only visible viewport tiles
for (let x = x0; x <= x1; x++) drawFogTile(ctx, world, x, y);
}
}Benefits:
- Detection, minimap, and AI stay correct regardless of camera position.
- Render cost still scales with the viewport, not the map — the safe place to cull.
Reference: rot.js field-of-view documentation
Share One Refcounted Visibility Buffer Per Team
In an RTS, every unit on a team contributes to one shared fog map, so giving each unit its own visibility buffer and OR-ing all of them every frame is O(units) per tile and per frame. Keep a single per-team reference-count buffer (update-refcount-visibility): each unit's field of view increments the tiles it sees, and a tile is visible to the team when its count is above zero. Querying or rendering team visibility is then one buffer lookup regardless of army size.
Incorrect (per-unit buffers unioned every frame):
function teamVisible(team: Unit[], i: number): boolean {
// O(units) per tile query, and the union is rebuilt every frame.
return team.some((u) => u.visible[i] === 1);
}Correct (one shared refcounted buffer):
interface Team {
seenBy: Uint16Array; // count of team units currently seeing tile i
explored: Uint32Array;
}
function teamVisible(team: Team, i: number): boolean {
return team.seenBy[i] > 0; // O(1) regardless of army size
}
// On unit move, only that unit's old/new FOV adjust the shared counts
// (see update-refcount-visibility) — no per-frame union over all units.Benefits:
- Visibility queries and rendering are O(1) per tile no matter how many units exist.
- One buffer per team instead of one per unit cuts memory linearly with army size.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Find Edit-Affected Viewers With a Spatial Index
When a wall is destroyed, only viewers whose sight radius reaches the changed tile need recomputing — but scanning the whole viewer list to find them is O(viewers) per edit, which dominates once you have hundreds of units. Bucket viewers into a coarse spatial grid keyed by chunkX, chunkY; an edit queries only the buckets within sight range of the changed tile, so the cost scales with local density, not total population.
Incorrect (scan every viewer per edit):
function onWallDestroyed(world: World, tx: number, ty: number): void {
for (const v of world.viewers) { // O(viewers) for one local edit
if (within(v, tx, ty, v.sight)) recomputeFov(world, v);
}
}Correct (query a spatial bucket index):
const CELL = 16; // bucket size in tiles
const buckets = new Map<number, Set<Viewer>>();
const bkey = (x: number, y: number): number => ((y / CELL) | 0) * bucketsWide + ((x / CELL) | 0);
function onWallDestroyed(world: World, tx: number, ty: number, maxSight: number): void {
const reach = Math.ceil(maxSight / CELL);
const bx = (tx / CELL) | 0;
const by = (ty / CELL) | 0;
for (let cy = by - reach; cy <= by + reach; cy++) {
for (let cx = bx - reach; cx <= bx + reach; cx++) {
const here = buckets.get(cy * bucketsWide + cx);
if (here) for (const v of here) if (within(v, tx, ty, v.sight)) recomputeFov(world, v);
}
}
}When NOT to use this pattern:
- A handful of viewers — the linear scan is cheaper than maintaining bucket membership on every move.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Use a Bitset for Boolean Visibility Layers
A purely boolean layer — visible or not — stored as a Uint8Array spends eight bits to record one, and clearing it each recompute writes width × height bytes. A bitset packs 32 tiles into each Uint32Array word: 8× less memory, and fill(0) clears 32 tiles per write, which matters because the transient visible layer is wiped on every recompute. Use it when a tile needs no per-tile metadata beyond a single flag.
Incorrect (one byte per boolean):
const visible = new Uint8Array(width * height); // 1 byte to store 1 bit
visible[i] = 1;
const lit = visible[i] === 1;
visible.fill(0); // writes width*height bytes every frameCorrect (packed bitset):
class BitGrid {
private readonly words: Uint32Array;
constructor(public readonly width: number, public readonly height: number) {
this.words = new Uint32Array(Math.ceil((width * height) / 32));
}
get(i: number): boolean { return (this.words[i >>> 5] & (1 << (i & 31))) !== 0; }
set(i: number): void { this.words[i >>> 5] |= 1 << (i & 31); }
unset(i: number): void { this.words[i >>> 5] &= ~(1 << (i & 31)); }
clearAll(): void { this.words.fill(0); } // 32 tiles cleared per word write
}When NOT to use this pattern:
- Multi-viewer reference counting needs a per-tile integer, not a bit — use
Uint16Arraycounts (update-refcount-visibility). - When you also need explored/opaque per tile, bit flags in one byte (
state-three-state-encoding) are simpler than parallel bitsets.
Reference: MDN — Uint32Array
Index a Flat Buffer With y times width plus x, Not Nested Arrays
A nested grid[y][x] layout allocates one sub-array per row, so a tile read first dereferences the outer array to find the row, then indexes the row — two memory hops, and the rows themselves sit scattered across the heap. A single flat buffer indexed by y * width + x is one contiguous block: one hop per access, the whole grid clears in a single fill, and adjacent tiles are adjacent in memory.
Incorrect (nested per-row arrays):
const grid: Uint8Array[] = [];
for (let y = 0; y < height; y++) grid[y] = new Uint8Array(width);
const lit = grid[y][x]; // dereference row array, then index it
grid.forEach((row) => row.fill(0)); // height separate clears, scattered rowsCorrect (single flat buffer):
const grid = new Uint8Array(width * height);
const idx = (x: number, y: number): number => y * width + x;
const lit = grid[idx(x, y)]; // one contiguous read
grid.fill(0); // clears the entire grid in one passBenefits:
- Neighbour lookups become index arithmetic (
i - widthis the tile above), enabling cheap edge/corner handling. - One allocation instead of
height + 1, and onefill(0)to reset the frame.
Reference: MDN — Typed arrays
Avoid String-Keyed Maps for Per-Tile Visibility
Keying visibility by a template-literal coordinate in a Map or Set allocates and hashes a fresh string on every tile touch, and the entries live as scattered heap objects the GC must trace — orders of magnitude slower than a typed-array index, and impossible to clear with a single fill(0). Convert the coordinate to the integer index y * width + x and index a flat buffer instead.
Incorrect (string-keyed Set):
const visible = new Set<string>();
// Each call allocates a string and computes a hash.
const reveal = (x: number, y: number): void => { visible.add(`${x},${y}`); };
const isVisible = (x: number, y: number): boolean => visible.has(`${x},${y}`);
const clearFrame = (): void => visible.clear(); // frees scattered string entriesCorrect (integer index into a typed array):
const visible = new Uint8Array(width * height);
const reveal = (x: number, y: number): void => { visible[y * width + x] = 1; };
const isVisible = (x: number, y: number): boolean => visible[y * width + x] === 1;
const clearFrame = (): void => visible.fill(0); // single contiguous wipeWhen NOT to use this pattern:
- Sparse, unbounded coordinate spaces (e.g. an infinite procedural world with no fixed
width) where a dense buffer would be mostly empty — there, key aMapby a packed integer ((x << 16) | (y & 0xffff)), still avoiding string allocation.
Reference: MDN — Typed arrays
Iterate Row-Major to Match the Buffer's Memory Layout
A flat tile buffer is laid out row by row, so iterating with the x loop innermost walks contiguous memory and streams whole cache lines at once. Iterating with y innermost jumps width elements every step, missing the cache on nearly every access — the same loop body can run several times slower purely from iteration order on a large map. Hoist the row base out of the inner loop so the index is a single add.
Incorrect (column-major — cache miss per access):
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
// Each step jumps `width` elements forward — defeats the cache line.
accumulate(fog[y * width + x]);
}
}Correct (row-major with hoisted row base):
for (let y = 0; y < height; y++) {
const row = y * width; // computed once per row
for (let x = 0; x < width; x++) {
accumulate(fog[row + x]); // contiguous, cache-friendly
}
}Benefits:
- Sequential access lets the prefetcher and SIMD-friendly JIT keep the pipeline full.
- Hoisting
rowremoves a multiply from the innermost loop.
Reference: MDN — Typed arrays
Encode the Three Fog States as Bit Flags in One Byte
Fog of war has three display states — never seen, explored-but-not-visible, and currently visible — and representing them as per-tile strings or enums forces string comparisons and 8+ bytes per tile, while three parallel boolean arrays triple the memory traffic of every update. Pack the orthogonal facts (visible, explored, opaque) into bit flags in a single byte: state tests become branchless bit masks, and one buffer holds everything.
Incorrect (string state per tile):
type FogState = "unseen" | "explored" | "visible";
const state: FogState[] = new Array(width * height).fill("unseen");
// String compares on the hot path; boxed strings bloat memory.
if (state[i] === "visible") drawBright(i);
else if (state[i] === "explored") drawDim(i);Correct (bit flags in a Uint8Array):
const VISIBLE = 1; // bit 0 — currently in sight
const EXPLORED = 2; // bit 1 — seen at least once (sticky)
const OPAQUE = 4; // bit 2 — blocks sight
const fog = new Uint8Array(width * height);
const f = fog[i];
if (f & VISIBLE) drawBright(i);
else if (f & EXPLORED) drawDim(i);
// Reveal sets both bits at once; clearing visibility leaves explored intact.
fog[i] |= VISIBLE | EXPLORED;
fog[i] &= ~VISIBLE; // tile leaves sight but stays rememberedBenefits:
- One byte per tile holds all fog facts; queries are single AND operations.
- Setting visible and explored together is one OR; the explored bit is never accidentally cleared (
correct-explored-not-overwritten).
Reference: MDN — Bitwise operators
Store Fog State in Typed Arrays, Not Arrays of Objects
Storing each tile as an object in a 2D array means every tile read chases a pointer to a heap object scattered across memory, defeating the CPU cache, and it creates width × height objects the garbage collector must scan on every cycle. Parallel typed arrays store one fixed-width value per tile contiguously, so a full sweep streams through cache lines and allocates nothing per tile.
Incorrect (array of heap objects):
interface Tile { visible: boolean; explored: boolean; opaque: boolean; }
const grid: Tile[][] = [];
for (let y = 0; y < height; y++) {
grid[y] = [];
for (let x = 0; x < width; x++) {
grid[y][x] = { visible: false, explored: false, opaque: false };
}
}
// A 1000x1000 map = 1,000,000 heap objects for the GC to trace, each a pointer hop away.
const lit = grid[y][x].visible;Correct (parallel typed arrays):
const visible = new Uint8Array(width * height);
const explored = new Uint8Array(width * height);
const opaque = new Uint8Array(width * height);
const lit = visible[y * width + x] === 1; // contiguous read, zero GC pressureBenefits:
- The visible buffer clears in one
fill(0)instead of touching a million objects. - Contiguous layout lets the JIT vectorise sweeps and keeps data in cache.
Reference: MDN — Typed arrays
Batch Map Edits and Recompute Affected Viewers Once
Destructible terrain usually changes many tiles in one logical action — an explosion clears a 5×5 area, a door opens, a wall collapses — and recomputing field of view after each individual tile edit repeats the sweep once per tile for a single event. Collect edits into a dirty-tile set during the action, then at end of frame recompute only the viewers whose sight radius overlaps any dirty tile, exactly once.
Incorrect (recompute per tile edited):
function setOpaque(world: World, x: number, y: number, opaque: boolean): void {
world.grid.set(x, y, opaque);
recomputeAllViewers(world); // a 25-tile explosion runs this 25 times
}Correct (queue edits, flush once):
function setOpaque(world: World, x: number, y: number, opaque: boolean): void {
world.grid.set(x, y, opaque);
world.dirtyTiles.add(y * world.grid.width + x); // just record it
}
function endFrame(world: World): void {
if (world.dirtyTiles.size === 0) return;
for (const v of world.viewers) {
if (radiusOverlapsAny(v, world.dirtyTiles, world.grid.width)) {
computeFovInto(world, v); // only viewers near the change, once
}
}
world.dirtyTiles.clear();
}Benefits:
- One explosion costs one recompute per nearby viewer instead of one per changed tile.
- Distant viewers that cannot see the edit are skipped entirely.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Emit Visibility Deltas Instead of Clear-All-Recompute
Clearing the whole visible buffer and handing the renderer a fresh full set forces it to treat every lit tile as changed, even though stepping one tile only flips a thin crescent of tiles at the radius edge. Diff the new field of view against the previous one and emit just the newly-visible and newly-hidden tiles. Downstream work — re-tinting sprites, updating a lightmap, uploading texture regions — then scales with the actual change, not the radius.
Incorrect (treat the whole radius as changed):
function updateFov(state: VState, cx: number, cy: number, r: number): void {
state.visible.fill(0);
computeFov(state.grid, cx, cy, r);
redrawEveryVisibleTile(state); // re-touches the entire lit disc each step
}Correct (diff and emit only the changes):
function updateFov(state: VState, cx: number, cy: number, r: number): number[] {
const { curr, prev } = state;
prev.set(curr); // snapshot last frame's visibility
curr.fill(0);
computeFov(state.grid, cx, cy, r); // writes into curr via setVisible
const changed: number[] = [];
// Only the box either frame could touch needs diffing (see fov-radius-bounded-scan).
forEachIndexInBox(cx, cy, r + 1, state.width, (i) => {
if (curr[i] !== prev[i]) {
changed.push(i);
if (curr[i] === 1) state.explored[i] = 1;
}
});
return changed; // hand only these to the renderer / lightmap
}Benefits:
- Render cost tracks movement, not sight radius — a one-tile step updates a handful of tiles.
- Pairs naturally with dirty-region rendering (
render-dirty-region-only).
Reference: rot.js field-of-view documentation
Track a Dirty Flag Per Viewer and a Map Version Stamp
With many independent viewers, recomputing all of them whenever any one moves does work proportional to the total viewer count on every single move. Give each viewer a dirty flag set on its own movement, plus a global integer map-version that increments on any map edit. A viewer needs recomputing only if it is dirty or its cached map-version is stale, so churn from one unit no longer drags in the rest.
Incorrect (recompute everyone on any change):
function onAnyChange(world: World): void {
for (const v of world.viewers) {
// Even viewers that did not move and saw no map change are re-swept.
computeFovInto(world, v);
}
}Correct (per-viewer dirty + map version):
interface Viewer {
x: number; y: number; sight: number;
dirty: boolean;
seenMapVersion: number;
}
function moveViewer(v: Viewer, nx: number, ny: number): void {
if (nx !== v.x || ny !== v.y) { v.x = nx; v.y = ny; v.dirty = true; }
}
function recomputeDirty(world: World): void {
for (const v of world.viewers) {
if (v.dirty || v.seenMapVersion !== world.mapVersion) {
computeFovInto(world, v);
v.dirty = false;
v.seenMapVersion = world.mapVersion;
}
}
}Benefits:
- One unit walking re-sweeps one unit, not the whole army.
- A map edit invalidates everyone with a single counter bump (
world.mapVersion++) — no per-viewer flag fan-out.
Reference: rot.js field-of-view documentation
Merge Visible Into Explored as You Reveal, Never Rebuild It
The "explored" (remembered) layer is the cumulative union of everything ever seen, so deriving it by re-scanning the map or re-OR-ing every viewer each frame redoes work that never needs redoing. Treat explored as monotonic: the instant a tile becomes visible, set its explored bit, and never clear it. The merge is then a single write at reveal time — O(1) per newly-lit tile — instead of a full-map pass.
Incorrect (rebuild explored from all viewers each frame):
function rebuildExplored(world: World): void {
// Re-derives the entire memory layer every frame from scratch.
for (let i = 0; i < world.explored.length; i++) {
let seen = world.explored[i];
for (const v of world.viewers) {
if (everSaw(v, i)) seen = 1;
}
world.explored[i] = seen;
}
}Correct (set the explored bit at reveal time):
function reveal(world: World, i: number): void {
world.visible[i] = 1;
world.explored[i] = 1; // one write, set-once, never cleared
}
function clearVisibleForNewFrame(world: World): void {
world.visible.fill(0); // wipe transient visibility only
// explored is left untouched — it only ever grows
}Warning (this rule is about cost, not the clear bug):
- This rule is about not rebuilding explored (O(map) per frame to O(1) at reveal). The related correctness failure — collapsing visible and explored into one tristate value, so clearing visibility erases memory — is a separate concern covered in
correct-explored-not-overwritten.
Reference: rot.js field-of-view documentation
Recompute Field of View Only When the Viewer Moves
Field of view depends only on the viewer's position and the map, and neither changes on most frames — yet the single most common fog-of-war performance bug is calling computeFov() inside the render loop. At 60fps a radius-12 sweep that recomputes nothing new runs 60 times a second for no reason. Cache the visibility result and recompute only when the viewer's tile changes or the map mutates, turning a per-frame cost into an occasional one.
Incorrect (recompute every frame):
function frame(state: GameState): void {
// Full sweep every animation frame, even standing still.
state.visible.fill(0);
computeFov(state.grid, state.player.x, state.player.y, state.player.sight);
renderFog(state);
requestAnimationFrame(() => frame(state));
}Correct (recompute on change only):
// Initialise the cached tile to a sentinel no tile can hold, so the first
// frame always computes — even if the player legitimately starts at (0, 0).
state.lastFovX = -1;
state.lastFovY = -1;
function frame(state: GameState): void {
const { player } = state;
const moved = player.x !== state.lastFovX || player.y !== state.lastFovY;
if (moved || state.mapDirty) {
state.visible.fill(0);
computeFov(state.grid, player.x, player.y, player.sight);
state.lastFovX = player.x;
state.lastFovY = player.y;
state.mapDirty = false;
}
renderFog(state); // rendering can still run every frame for camera/animation
requestAnimationFrame(() => frame(state));
}Warning (sentinel, not zero):
- Initialise
lastFovX/lastFovYto-1(or any off-map value), not0. With0, a player who spawns on tile(0, 0)would compare equal on the first frame and never get an initial FOV.
Benefits:
- A stationary player costs zero FOV work; movement costs one sweep per tile stepped.
- Decouples FOV cost from frame rate, so a 144Hz display does not triple FOV cost.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Count Viewers Per Tile for Incremental Multi-Viewer Updates
When several units share one visibility layer, a boolean "visible" flag cannot answer whether a tile is still seen by another unit after one unit looks away — so the only safe move is to clear everything and re-union all units. Store a per-tile integer count of how many viewers currently see it: increment when a viewer reveals a tile, decrement when it stops. The tile is visible while the count is above zero, so a single unit's move only touches its own old and new field of view.
Incorrect (boolean flag forces full rebuild):
function onUnitMoved(world: World): void {
world.visible.fill(0); // cannot tell which tiles others still see
for (const u of world.units) {
forEachVisible(world.grid, u.x, u.y, u.sight, (i) => { world.visible[i] = 1; });
}
}Correct (per-tile reference count):
interface World {
grid: Grid;
seenBy: Uint16Array; // how many units currently see tile i
visible: Uint8Array; // derived: seenBy[i] > 0
explored: Uint8Array;
}
function moveUnit(world: World, u: Unit, nx: number, ny: number): void {
forEachVisible(world.grid, u.x, u.y, u.sight, (i) => {
if (--world.seenBy[i] === 0) world.visible[i] = 0; // last viewer left
});
u.x = nx;
u.y = ny;
forEachVisible(world.grid, nx, ny, u.sight, (i) => {
if (world.seenBy[i]++ === 0) world.visible[i] = 1; // first viewer arrived
world.explored[i] = 1;
});
}When NOT to use this pattern:
- A single viewer (one player) — a plain boolean/bitset (
state-bitset-layers) is simpler and the rebuild is trivial.
Reference: Roguelike Vision Algorithms (Adam Milazzo)
Related skills
FAQ
What does fog-of-war-js-ts do?
fog-of-war-js-ts is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use fog-of-war-js-ts?
When you need to helps with ai & agent building tasks during ai-assisted development, or when fog-of-war-js-ts is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
fog-of-war-js-ts; AI & Agent Building; AI-coding skill.