Explorar o código

perf(terrain): detach search in three phases, one search per tick, leaner chunk creation

A whole tier coming loose cost ~30 ms of server tick; it now costs ~5 ms.
An ordinary op averages ~0.1 ms (p99 ~1 ms), all under plain Node.

- DetachFinder: breadth-first on a small budget, then a downward
  depth-first walk, then a scanline fill for big components (a fresh
  standard fill: contiguous rows are several times cheaper than a pixel
  walk). Coordinates stored with each pixel, unrolled neighbours.
- Working buffers shared per map size (a new Game every round no longer
  pays for 44 MB of fresh pages), stacks sized for MAX_COMPONENT_PIXELS.
- Game.step applies all of a tick's ops, then runs one detach search over
  every changed area: what one area proves anchored serves the others.
- Server applies a detach from the component mask directly (applyCells,
  same cells as the event), reuses the event's RLE for the chunk, averages
  materials through a histogram, counts box cells in one pass, and sets
  chunk fixture densities once (planck recomputed the mass per fixture).
- Contour extraction: early exit for all-empty/all-solid chunks, and only
  the entries written are reset (no 16k-entry clears per chunk).
- Hot loops read local copies of imported constants: under vitest each
  imported binding read is a property lookup, which made the vitest
  benchmarks several times too pessimistic. New npm run bench:node
  measures under plain Node.
- Network test waits for messages instead of a fixed delay (flaky under
  load).

Still above the 2 ms budget for the extreme case (single op severing a
whole tier): ~2 ms search + ~1 ms detach + ~1.5 ms colliders. Noted in
the readme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman hai 1 semana
pai
achega
2c5970ab29

+ 1 - 0
package.json

@@ -9,6 +9,7 @@
     "server": "tsx src/server/index.ts",
     "test": "vitest run",
     "bench": "vitest bench --run",
+    "bench:node": "tsx scripts/bench-terrain.ts",
     "lint": "eslint . && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
     "build": "electron-vite build",
     "dist": "electron-vite build && electron-builder --win",

+ 8 - 1
readme.md

@@ -42,6 +42,7 @@ npm run server         # serveur headless en Node pur (tests, bots, debug)
                        #   ex. : npm run server -- --name="Test" --round=30 --rounds=2 --pause=5
 npm test               # vitest
 npm run bench          # benchmarks vitest (budget terrain §5)
+npm run bench:node     # même budget mesuré sous Node, sans le biais du lanceur de tests
 npm run gen:maps       # régénère les PNG placeholder des cartes intégrées
 npm run lint           # eslint + tsc --noEmit
 npm run build          # build de prod
@@ -184,6 +185,12 @@ type TerrainOp =
 - Une op avec flood-fill et régénération des colliders doit tenir en **2 ms** côté serveur. Si ce n'est pas le cas, on optimise : flood-fill borné, bitsets, cache des composantes.
 - 60 FPS sur un PC milieu de gamme avec 8 joueurs et 20 blocs en chute.
 - Écrire un **benchmark vitest** pour les ops et le flood-fill dès la première étape.
+- **Mesurer sous Node** (`npm run bench:node`). Les benchmarks vitest sont pessimistes sur les boucles chaudes : le lanceur de tests transforme chaque lecture d'une constante importée en accès de propriété. Dans les modules de terrain, les boucles chaudes lisent donc des copies locales des constantes (`const EMPTY = MATERIAL_EMPTY`).
+- **État mesuré** (Node, JIT chaud) :
+  - un coup ordinaire (op, recherche de détachement, colliders, pas physique) : environ **0,1 ms** en moyenne, ~1 ms au p99 ;
+  - un coup unique qui détache un étage entier (~90 000 px) : ~**5 ms** pour tout le tick, dont ~2 ms de recherche, ~1 ms de détachement et ~1,5 ms de colliders.
+
+  Ce cas extrême reste au-dessus de 2 ms. Il faudrait étaler le travail sur plusieurs ticks, ou ajouter un cache de connectivité entre morceaux.
 ---
  
 ## 6. Cartes
@@ -281,7 +288,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
  
 - Flood-fill vers l'ancre Assiette, `FallingChunk`, réintégration du bloc immobile, écrasement.
 - **Terminé quand :** creuser sous un étage le fait tomber et l'étage se pose proprement.
-- **État :** implémenté et testé (détachement, chute, réintégration, écrasement, cohérence serveur/clients). Reste au-dessus du budget §5 : le décrochage d'un étage entier (~90 000 px) prend ~9 ms, car le flood-fill doit parcourir tout le bloc. C'est rare, mais à optimiser (balayage par lignes, bitsets).
+- **État :** implémenté et testé (détachement, chute, réintégration, écrasement, cohérence serveur/clients). Recherche en trois phases (largeur, profondeur vers le bas, puis remplissage par lignes), une seule recherche par tick, mémoire de travail partagée : voir §5 pour les mesures. Le décrochage d'un étage entier reste à ~5 ms par tick, au-dessus des 2 ms.
 ### M3 : Multijoueur LAN
  
 - Serveur dans un `utilityProcess`, mode headless, WebSocket, protocole msgpackr.

+ 83 - 0
scripts/bench-terrain.ts

@@ -0,0 +1,83 @@
+// Terrain budget benchmark under plain Node (readme §5: an op + flood fill + colliders in 2 ms).
+// Usage: npm run bench:node
+// The vitest benchmarks run through the test runner's module transform, which slows hot loops
+// that read imported bindings; this script measures what the server actually runs.
+import { World } from 'planck';
+import { loadCharacterStats, loadMap, loadMaterials, resolveAssetsDir } from '../src/server/assets';
+import { Game } from '../src/server/game';
+import { TerrainColliders } from '../src/server/physics/terrainColliders';
+import { Rng } from '../src/shared/rng';
+import { DetachFinder } from '../src/shared/terrain/detach';
+import { applyCells } from '../src/shared/terrain/events';
+import { applyOp } from '../src/shared/terrain/ops';
+
+const assets = resolveAssetsDir();
+const materials = loadMaterials(assets);
+const WARMUP = 3;
+
+function report(label: string, samples: number[]): void {
+  const warm = samples.slice(WARMUP).sort((a, b) => a - b);
+  const pick = (q: number): string => warm[Math.min(warm.length - 1, Math.floor(warm.length * q))].toFixed(2);
+  const mean = warm.reduce((a, b) => a + b, 0) / warm.length;
+  console.log(`${label.padEnd(44)} mean ${mean.toFixed(2)}  median ${pick(0.5)}  p99 ${pick(0.99)}  max ${pick(1)} ms`);
+}
+
+function newGame(): Game {
+  const { definition, terrain } = loadMap(assets, 'gateau-etages', materials);
+  return new Game({ map: definition, terrain, materials, seed: 1, loadCharacter: (id) => loadCharacterStats(assets, id) });
+}
+
+// 1. Ordinary ops on a cake turning into Swiss cheese: op + detach search + colliders + step.
+{
+  const { terrain } = loadMap(assets, 'gateau-etages', materials);
+  const world = new World();
+  const colliders = new TerrainColliders(world, terrain);
+  colliders.rebuildDirty();
+  const finder = new DetachFinder(terrain);
+  const rng = new Rng(7);
+  const samples: number[] = [];
+  for (let i = 0; i < 1500; i++) {
+    const t = performance.now();
+    const { changed } = applyOp(terrain, { kind: 'carve', x: rng.int(300, 1700), y: rng.int(300, 900), r: 40, power: 99 }, materials);
+    if (changed) {
+      for (const c of finder.find(changed)) {
+        const { x0, y0, x1, y1 } = c.bounds;
+        applyCells(terrain, 'detach', x0, y0, x1 - x0, y1 - y0, c.mask);
+      }
+    }
+    colliders.rebuildDirty();
+    world.step(1 / 60);
+    samples.push(performance.now() - t);
+  }
+  report('carve r=40 (op + detach + colliders + step)', samples.slice(200 - WARMUP));
+}
+
+// 2. One op severing the last link of the top tier (~90k px come loose): the worst single op.
+{
+  const samples: number[] = [];
+  for (let round = 0; round < WARMUP + 10; round++) {
+    const game = newGame();
+    for (let x = 760; x <= 1250; x += 12) {
+      game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
+      game.step();
+    }
+    game.queueOp({ kind: 'carve', x: 1268, y: 462, r: 16, power: 99 });
+    const t = performance.now();
+    game.step();
+    samples.push(performance.now() - t);
+  }
+  report('single op severing a whole tier (full tick)', samples);
+}
+
+// 3. A 44-op cut in a single tick (e.g. several players at once).
+{
+  const samples: number[] = [];
+  for (let round = 0; round < WARMUP + 10; round++) {
+    const game = newGame();
+    for (let x = 760; x <= 1278; x += 12) game.queueOp({ kind: 'carve', x, y: 462, r: 14, power: 99 });
+    const t = performance.now();
+    game.step();
+    samples.push(performance.now() - t);
+  }
+  report('44-op cut in one tick (full tick)', samples);
+}

+ 15 - 5
src/server/game.ts

@@ -61,9 +61,9 @@ import type { MapDefinition } from '../shared/maps/loader';
 import type { MaterialRegistry } from '../shared/materials';
 import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
 import { Rng } from '../shared/rng';
-import type { TerrainBitmap } from '../shared/terrain/bitmap';
+import type { Rect, TerrainBitmap } from '../shared/terrain/bitmap';
 import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
-import { applyTerrainEvent, rasterizeChunk, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
+import { applyCells, applyTerrainEvent, rasterizeChunk, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
 import type { TerrainOp } from '../shared/terrain/ops';
 import { rleEncode } from '../shared/terrain/rle';
 import { MODIFIERS, type Modifier, type ModifierContext } from './modifiers';
@@ -361,14 +361,22 @@ export class Game {
 
     const pending = this.pendingOps;
     this.pendingOps = [];
+    // All of this tick's ops first, then one detach search over every changed area: what one
+    // area proves anchored speeds up the others (a cut is often dozens of ops in one tick).
+    const changedRects: Rect[] = [];
+    const changedBy: number[] = [];
     for (const { op, owner, onApplied } of pending) {
       const { changed, removedByMaterial } = this.emit({ kind: 'op', op }, result);
       let removed = 0;
       for (const n of removedByMaterial.values()) removed += n;
       this.stats.add(owner, 'eaten', removed);
       onApplied?.(removed);
-      if (changed) for (const component of this.finder.find(changed)) this.detach(component, owner, result);
+      if (changed) {
+        changedRects.push(changed);
+        changedBy.push(owner);
+      }
     }
+    if (changedRects.length > 0) for (const component of this.finder.find(changedRects)) this.detach(component, changedBy[component.region], result);
     this.syncTerrain(result);
 
     this.crushedThisStep.clear();
@@ -466,9 +474,11 @@ export class Game {
     const h = y1 - y0;
     const debris = c.pixelCount < MIN_CHUNK_PIXELS;
     const id = this.nextChunkId++;
-    this.emit({ kind: 'detach', id, x: x0, y: y0, w, h, mask: rleEncode(c.mask), debris }, result);
+    const mask = rleEncode(c.mask);
+    // Same effect as applying the event, without decoding the RLE we just encoded.
+    if (applyCells(this.terrain, 'detach', x0, y0, w, h, c.mask).changed) this.log({ kind: 'detach', id, x: x0, y: y0, w, h, mask, debris }, result);
     if (debris) return;
-    this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials));
+    this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials, mask));
     this.stats.add(owner, 'detached');
   }
 

+ 38 - 20
src/server/physics/fallingChunk.ts

@@ -4,6 +4,10 @@ import type { MaterialRegistry } from '../../shared/materials';
 import type { ChunkInfo, ChunkState } from '../../shared/protocol';
 import { rleEncode } from '../../shared/terrain/rle';
 
+// Local copies: hot loops must not read imported bindings (some module loaders, e.g. the test
+// runner's, turn each read into a property lookup on the module object).
+const EMPTY = MATERIAL_EMPTY;
+
 const PPM = PIXELS_PER_METER;
 
 export interface FixtureTag {
@@ -30,18 +34,21 @@ export function boxesForMask(mask: Uint8Array, w: number, h: number): BoxRect[]
   const cell = Math.max(CHUNK_BOX_CELL_PX, Math.ceil(Math.max(w, h) / CHUNK_MAX_BOXES_PER_AXIS));
   const gw = Math.ceil(w / cell);
   const gh = Math.ceil(h / cell);
+  // Count solid pixels per cell in a single pass over the mask (a whole tier is ~100k pixels).
+  const counts = new Uint32Array(gw * gh);
+  const cellOfX = new Int32Array(w);
+  for (let x = 0; x < w; x++) cellOfX[x] = Math.floor(x / cell);
+  for (let y = 0; y < h; y++) {
+    const rowCell = Math.floor(y / cell) * gw;
+    const row = y * w;
+    for (let x = 0; x < w; x++) if (mask[row + x] !== EMPTY) counts[rowCell + cellOfX[x]]++;
+  }
   const solid = new Uint8Array(gw * gh);
   for (let gy = 0; gy < gh; gy++) {
+    const ch = Math.min(h, (gy + 1) * cell) - gy * cell;
     for (let gx = 0; gx < gw; gx++) {
-      let n = 0;
-      let total = 0;
-      for (let y = gy * cell; y < Math.min(h, (gy + 1) * cell); y++) {
-        for (let x = gx * cell; x < Math.min(w, (gx + 1) * cell); x++) {
-          total++;
-          if (mask[y * w + x] !== MATERIAL_EMPTY) n++;
-        }
-      }
-      solid[gy * gw + gx] = n * 3 >= total ? 1 : 0;
+      const total = (Math.min(w, (gx + 1) * cell) - gx * cell) * ch;
+      solid[gy * gw + gx] = counts[gy * gw + gx] * 3 >= total ? 1 : 0;
     }
   }
 
@@ -94,33 +101,44 @@ export class FallingChunk {
     x: number,
     y: number,
     materials: MaterialRegistry,
+    /** The mask already RLE-encoded (the detach event has it), to avoid encoding it twice. */
+    maskRle?: number[],
   ) {
-    this.maskRle = rleEncode(mask);
+    this.maskRle = maskRle ?? rleEncode(mask);
+    // Average material properties, weighted by pixel count: a histogram first, so the per-pixel
+    // loop stays a tight typed-array loop (a whole tier is ~100k pixels).
+    const histogram = new Uint32Array(256);
+    for (let i = 0; i < mask.length; i++) histogram[mask[i]]++;
     let density = 0;
     let friction = 0;
     let restitution = 0;
     let n = 0;
-    for (const m of mask) {
-      const mat = m === MATERIAL_EMPTY ? undefined : materials.byId[m];
+    for (let m = 0; m < 256; m++) {
+      const count = histogram[m];
+      const mat = m === EMPTY || count === 0 ? undefined : materials.byId[m];
       if (!mat) continue;
-      density += mat.density;
-      friction += mat.friction;
-      restitution += mat.restitution;
-      n++;
+      density += mat.density * count;
+      friction += mat.friction * count;
+      restitution += mat.restitution * count;
+      n += count;
     }
     n = Math.max(1, n);
     this.body = world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, userData: { chunk: id } });
     const tag: FixtureTag = { chunk: id };
-    for (const b of boxesForMask(mask, w, h)) {
+    // Fixtures are created massless and get their density afterwards: planck recomputes the
+    // body's mass on every createFixture with a density, which is quadratic in the box count.
+    const fixtures = boxesForMask(mask, w, h).map((b) => {
       const hw = (b.x1 - b.x0) / 2;
       const hh = (b.y1 - b.y0) / 2;
-      this.body.createFixture(new Box(hw / PPM, hh / PPM, { x: (b.x0 + hw) / PPM, y: (b.y0 + hh) / PPM }), {
-        density: density / n,
+      return this.body.createFixture(new Box(hw / PPM, hh / PPM, { x: (b.x0 + hw) / PPM, y: (b.y0 + hh) / PPM }), {
+        density: 0,
         friction: friction / n,
         restitution: Math.min(0.3, restitution / n),
         userData: tag,
       });
-    }
+    });
+    for (const f of fixtures) f.setDensity(density / n);
+    this.body.resetMassData();
   }
 
   state(): ChunkState {

+ 256 - 80
src/shared/terrain/detach.ts

@@ -8,11 +8,20 @@ export interface DetachedComponent {
   /** w × h materials, 0 where the pixel is not part of the component. */
   mask: Uint8Array;
   pixelCount: number;
+  /** Index of the region (in the list given to find) whose seed found it. */
+  region: number;
 }
 
 const STAMP_LIMIT = 0xfffffff0;
+// Local copies: the hot loops below must not read imported bindings (some module loaders, e.g.
+// the test runner's, turn each read into a property lookup on the module object).
+const EMPTY = MATERIAL_EMPTY;
+const PLATE = MATERIAL_PLATE;
+const MAX_PIXELS = MAX_COMPONENT_PIXELS;
 /** Pixels explored breadth-first before a search switches to a downward depth-first walk. */
 const LOCAL_SEARCH_BUDGET = 2048;
+/** Pixels walked (breadth then depth first) before a search switches to a scanline fill. */
+const WALK_BUDGET = 12_000;
 
 /**
  * Rule (readme §5): every solid pixel not 4-connected to a plate pixel falls.
@@ -20,161 +29,328 @@ const LOCAL_SEARCH_BUDGET = 2048;
  * The search is seeded from every solid pixel in `region` (the area an op just changed,
  * grown by one pixel). Each seed first explores breadth-first on a small budget, which
  * usually meets a pixel an earlier seed already proved anchored; then depth-first, downward
- * first, since plates sit under the terrain. Only detached components get explored completely.
+ * first, since plates sit under the terrain; then, for big components, by scanline fill.
+ * Only detached components get explored completely.
  *
  * Within one call, every pixel reached by a search that found an anchor is anchored too,
  * so later seeds stop as soon as they touch one.
  */
-export class DetachFinder {
+/**
+ * Working memory of the search. Tens of megabytes for a 2 Mpx map: allocated once per map size
+ * and shared by every finder (a new Game, hence a new finder, starts each round), so a round's
+ * first big search does not pay for fresh pages. Searches never overlap (single-threaded), and
+ * the stamp counters live here too so stamps stay unique across finders.
+ */
+interface SearchBuffers {
   /** Search id that last pushed each pixel. */
-  private readonly seen: Uint32Array;
+  seen: Uint32Array;
   /** Per call: 2·call = anchored, 2·call + 1 = detached. */
+  known: Uint32Array;
+  /** Walk stack (indices into `pushed`), then scanline seeds (pixel index and row). */
+  stack: Int32Array;
+  stackY: Int16Array;
+  /** Every pixel reached by the current search, with its coordinates. */
+  pushed: Int32Array;
+  pushedX: Int16Array;
+  pushedY: Int16Array;
+  searchId: number;
+  callId: number;
+}
+
+const sharedBuffers = new Map<string, SearchBuffers>();
+
+function buffersFor(width: number, height: number): SearchBuffers {
+  const key = `${width}x${height}`;
+  let b = sharedBuffers.get(key);
+  if (!b) {
+    const n = width * height;
+    // A component never exceeds MAX_PIXELS (plus the row being filled); scanline seeds are at most
+    // two per run.
+    const pushedSize = Math.min(n, MAX_PIXELS + width + 4);
+    const stackSize = Math.min(n, 2 * pushedSize + 4 * width);
+    b = {
+      seen: new Uint32Array(n),
+      known: new Uint32Array(n),
+      stack: new Int32Array(stackSize),
+      stackY: new Int16Array(stackSize),
+      pushed: new Int32Array(pushedSize),
+      pushedX: new Int16Array(pushedSize),
+      pushedY: new Int16Array(pushedSize),
+      searchId: 0,
+      callId: 0,
+    };
+    sharedBuffers.set(key, b);
+  }
+  return b;
+}
+
+export class DetachFinder {
+  private readonly seen: Uint32Array;
   private readonly known: Uint32Array;
   private readonly stack: Int32Array;
+  private readonly stackY: Int16Array;
   private readonly pushed: Int32Array;
-  private searchId = 0;
-  private callId = 0;
+  private readonly pushedX: Int16Array;
+  private readonly pushedY: Int16Array;
+  private readonly buffers: SearchBuffers;
 
   constructor(private readonly bitmap: TerrainBitmap) {
-    const n = bitmap.width * bitmap.height;
-    this.seen = new Uint32Array(n);
-    this.known = new Uint32Array(n);
-    this.stack = new Int32Array(n);
-    this.pushed = new Int32Array(n);
+    const b = buffersFor(bitmap.width, bitmap.height);
+    this.buffers = b;
+    this.seen = b.seen;
+    this.known = b.known;
+    this.stack = b.stack;
+    this.stackY = b.stackY;
+    this.pushed = b.pushed;
+    this.pushedX = b.pushedX;
+    this.pushedY = b.pushedY;
   }
 
-  /** Finds detached components touching `region`. Does not modify the bitmap. */
-  find(region: Rect): DetachedComponent[] {
+  /**
+   * Finds detached components touching `regions` (e.g. every area changed during a tick).
+   * Searching them all in one call lets what one region proved anchored speed up the others.
+   * Does not modify the bitmap.
+   */
+  find(regions: Rect | readonly Rect[]): DetachedComponent[] {
     const { width, height, data } = this.bitmap;
     const call = this.nextCall();
-    const x0 = Math.max(0, region.x0 - 1);
-    const y0 = Math.max(0, region.y0 - 1);
-    const x1 = Math.min(width, region.x1 + 1);
-    const y1 = Math.min(height, region.y1 + 1);
+    const list = Array.isArray(regions) ? regions : [regions as Rect];
     const out: DetachedComponent[] = [];
-    for (let y = y0; y < y1; y++) {
-      for (let x = x0; x < x1; x++) {
-        const i = y * width + x;
-        if (data[i] === MATERIAL_EMPTY) continue;
-        const k = this.known[i];
-        if (k === 2 * call || k === 2 * call + 1) continue;
-        const component = this.explore(i, call);
-        if (component) out.push(component);
+    list.forEach((region, r) => {
+      const x0 = Math.max(0, region.x0 - 1);
+      const y0 = Math.max(0, region.y0 - 1);
+      const x1 = Math.min(width, region.x1 + 1);
+      const y1 = Math.min(height, region.y1 + 1);
+      for (let y = y0; y < y1; y++) {
+        for (let x = x0; x < x1; x++) {
+          const i = y * width + x;
+          if (data[i] === EMPTY) continue;
+          const k = this.known[i];
+          if (k === 2 * call || k === 2 * call + 1) continue;
+          const component = this.explore(i, call);
+          if (component) out.push({ ...component, region: r });
+        }
       }
-    }
+    });
     return out;
   }
 
   /** True if the solid pixel at `index` is connected to a plate. */
   isAnchored(index: number): boolean {
-    if (this.bitmap.data[index] === MATERIAL_EMPTY) return false;
+    if (this.bitmap.data[index] === EMPTY) return false;
     return this.explore(index, this.nextCall()) === null;
   }
 
   private nextCall(): number {
-    if (this.callId >= STAMP_LIMIT / 2 || this.searchId >= STAMP_LIMIT) {
-      this.seen.fill(0);
-      this.known.fill(0);
-      this.callId = 0;
-      this.searchId = 0;
+    const b = this.buffers;
+    if (b.callId >= STAMP_LIMIT / 2 || b.searchId >= STAMP_LIMIT) {
+      b.seen.fill(0);
+      b.known.fill(0);
+      b.callId = 0;
+      b.searchId = 0;
     }
-    return ++this.callId;
+    return ++b.callId;
   }
 
-  /** DFS from `start`. Returns null if anchored, the component otherwise. Hot path: kept flat on purpose. */
+  /**
+   * Explores the component of `start`. Returns null if anchored, the component otherwise.
+   * Three phases, each the cheapest for a different situation:
+   * 1. Breadth-first on a small budget: spreads sideways and quickly meets pixels an earlier
+   *    search proved anchored (common after a cut, where many seeds share one anchor).
+   * 2. Depth-first, downward first, on a larger budget: plates sit under the terrain, so an
+   *    anchored component is usually proved by a narrow walk straight down.
+   * 3. Scanline fill: whole horizontal runs at a time. Reading contiguous memory is several
+   *    times faster than a pixel walk on a 2 Mpx map, which matters when a whole tier comes
+   *    loose and every one of its pixels must be visited.
+   * Hot path, kept flat on purpose.
+   */
   private explore(start: number, call: number): DetachedComponent | null {
     const { width, height, data } = this.bitmap;
-    const { seen, known, stack, pushed } = this;
+    const { seen, known, pushed, pushedX, pushedY, stack } = this;
     const anchoredMark = 2 * call;
-    const search = ++this.searchId;
-    const last = width * height - width; // first index of the bottom row
-    let count = 0;
+    const search = ++this.buffers.searchId;
+    const maxX = width - 1;
+    const maxY = height - 1;
+    const sx = start % width;
+    const sy = (start - sx) / width;
+    let count = 1;
     let anchored = false;
     seen[start] = search;
-    pushed[count++] = start;
+    pushed[0] = start;
+    pushedX[0] = sx;
+    pushedY[0] = sy;
 
-    // Phase 1: breadth-first on `pushed` (used as a queue) within a small budget. It spreads
-    // sideways and quickly meets pixels an earlier search already proved anchored.
-    // Phase 2, if still unresolved: depth-first on `stack`, heading for the plates below.
+    // ---- Phases 1 and 2: pixel walk. Phase 1 reads `pushed` as a queue; phase 2 uses `stack`
+    // (indices into `pushed`), pushing up, left and right before down so down is explored first.
     let head = 0;
     let top = 0;
     let deep = false;
-    search: for (;;) {
-      let i: number;
+    walk: while (true) {
+      let k: number;
       if (!deep) {
         if (head >= count) break;
         if (count >= LOCAL_SEARCH_BUDGET) {
           deep = true;
-          for (let k = head; k < count; k++) stack[top++] = pushed[k];
+          for (let q = head; q < count; q++) stack[top++] = q;
           continue;
         }
-        i = pushed[head++];
+        k = head++;
       } else {
-        if (top === 0) break;
-        i = stack[--top];
+        if (top === 0 || count >= WALK_BUDGET) break;
+        k = stack[--top];
       }
-      if (data[i] === MATERIAL_PLATE || count > MAX_COMPONENT_PIXELS) {
-        // Plate reached, or too big to fall: anchored.
+      const i = pushed[k];
+      if (data[i] === PLATE) {
         anchored = true;
         break;
       }
-      const x = i % width;
-      // In the deep phase the last pushed is explored first: up, left, right, then down.
+      const x = pushedX[k];
+      const y = pushedY[k];
       for (let d = 0; d < 4; d++) {
         let n: number;
+        let nx = x;
+        let ny = y;
         if (d === 0) {
-          if (i < width) continue;
+          if (y === 0) continue;
           n = i - width;
+          ny--;
         } else if (d === 1) {
           if (x === 0) continue;
           n = i - 1;
+          nx--;
         } else if (d === 2) {
-          if (x === width - 1) continue;
+          if (x === maxX) continue;
           n = i + 1;
+          nx++;
         } else {
-          if (i >= last) continue;
+          if (y === maxY) continue;
           n = i + width;
+          ny++;
         }
-        if (data[n] === MATERIAL_EMPTY || seen[n] === search) continue;
+        if (data[n] === EMPTY || seen[n] === search) continue;
         if (known[n] === anchoredMark) {
           anchored = true;
-          break search;
+          break walk;
         }
         seen[n] = search;
-        pushed[count++] = n;
-        if (deep) stack[top++] = n;
+        pushed[count] = n;
+        pushedX[count] = nx;
+        pushedY[count] = ny;
+        if (deep) stack[top++] = count;
+        count++;
+      }
+    }
+
+    // ---- Phase 3: the walk ran out of budget, so this is a big component (or a long winding
+    // path to an anchor). Start over with a scanline fill: revisiting the walked pixels is cheap
+    // there, and a fresh, standard fill is several times faster than resuming from the walk's
+    // scattered frontier.
+    if (!anchored && deep && top > 0) {
+      for (let q = 0; q < count; q++) known[pushed[q]] = 0; // nothing learned yet
+      count = this.scanFill(start, sy, ++this.buffers.searchId, anchoredMark);
+      if (count < 0) {
+        anchored = true;
+        count = -count;
       }
     }
 
     if (anchored) {
-      for (let k = 0; k < count; k++) known[pushed[k]] = anchoredMark;
+      for (let q = 0; q < count; q++) known[pushed[q]] = anchoredMark;
       return null;
     }
 
     // Exhausted without meeting an anchor: detached.
-    let minX = width;
-    let minY = height;
-    let maxX = -1;
-    let maxY = -1;
-    for (let k = 0; k < count; k++) {
-      const i = pushed[k];
-      known[i] = anchoredMark + 1;
-      const x = i % width;
-      const y = (i - x) / width;
-      if (x < minX) minX = x;
-      if (x > maxX) maxX = x;
-      if (y < minY) minY = y;
-      if (y > maxY) maxY = y;
+    let minBX = maxX;
+    let maxBX = 0;
+    let minBY = maxY;
+    let maxBY = 0;
+    for (let q = 0; q < count; q++) {
+      const x = pushedX[q];
+      const y = pushedY[q];
+      if (x < minBX) minBX = x;
+      if (x > maxBX) maxBX = x;
+      if (y < minBY) minBY = y;
+      if (y > maxBY) maxBY = y;
     }
-    const w = maxX - minX + 1;
-    const h = maxY - minY + 1;
+    const w = maxBX - minBX + 1;
+    const h = maxBY - minBY + 1;
     const mask = new Uint8Array(w * h);
-    for (let k = 0; k < count; k++) {
-      const i = pushed[k];
-      const x = i % width;
-      const y = (i - x) / width;
-      mask[(y - minY) * w + (x - minX)] = data[i];
+    const detachedMark = anchoredMark + 1;
+    for (let q = 0; q < count; q++) {
+      const i = pushed[q];
+      known[i] = detachedMark;
+      mask[(pushedY[q] - minBY) * w + (pushedX[q] - minBX)] = data[i];
+    }
+    return { bounds: { x0: minBX, y0: minBY, x1: maxBX + 1, y1: maxBY + 1 }, mask, pixelCount: count, region: 0 };
+  }
+
+  /**
+   * Phase 3 of explore(): standard scanline fill of the component of `start` (row `startY`),
+   * with a fresh `search` stamp. Writes the pixels it reaches to `pushed` from index 0 and
+   * returns their count, negated if an anchor (plate or known anchored pixel) was met.
+   */
+  private scanFill(start: number, startY: number, search: number, anchoredMark: number): number {
+    const { width, height, data } = this.bitmap;
+    const { seen, known, pushed, pushedX, pushedY, stack, stackY } = this;
+    const maxX = width - 1;
+    const maxY = height - 1;
+    let count = 0;
+    let top = 0;
+    stack[top] = start;
+    stackY[top++] = startY;
+    while (top > 0) {
+      const y = stackY[--top];
+      const s0 = stack[top];
+      if (seen[s0] === search) continue; // already filled by another run
+      const row = y * width;
+      let l = s0 - row;
+      while (l > 0 && data[row + l - 1] !== EMPTY && seen[row + l - 1] !== search) l--;
+      let r = s0 - row;
+      while (r < maxX && data[row + r + 1] !== EMPTY && seen[row + r + 1] !== search) r++;
+      for (let x = l; x <= r; x++) {
+        const n = row + x;
+        if (data[n] === PLATE || known[n] === anchoredMark) return -count;
+        seen[n] = search;
+        pushed[count] = n;
+        pushedX[count] = x;
+        pushedY[count] = y;
+        count++;
+      }
+      if (count > MAX_PIXELS) return -count; // too big to fall: anchored
+      // Seed the runs touching this one: above first, so rows below are explored first.
+      if (y > 0) {
+        const nrow = row - width;
+        let inRun = false;
+        for (let x = l; x <= r; x++) {
+          const n = nrow + x;
+          if (data[n] === EMPTY || seen[n] === search) {
+            inRun = false;
+          } else if (known[n] === anchoredMark) {
+            return -count;
+          } else if (!inRun) {
+            stack[top] = n;
+            stackY[top++] = y - 1;
+            inRun = true;
+          }
+        }
+      }
+      if (y < maxY) {
+        const nrow = row + width;
+        let inRun = false;
+        for (let x = l; x <= r; x++) {
+          const n = nrow + x;
+          if (data[n] === EMPTY || seen[n] === search) {
+            inRun = false;
+          } else if (known[n] === anchoredMark) {
+            return -count;
+          } else if (!inRun) {
+            stack[top] = n;
+            stackY[top++] = y + 1;
+            inRun = true;
+          }
+        }
+      }
     }
-    return { bounds: { x0: minX, y0: minY, x1: maxX + 1, y1: maxY + 1 }, mask, pixelCount: count };
+    return count;
   }
 }

+ 42 - 16
src/shared/terrain/events.ts

@@ -4,6 +4,10 @@ import type { Rect, TerrainBitmap } from './bitmap';
 import { applyOp, isValidOp, type TerrainOp } from './ops';
 import { rleDecode } from './rle';
 
+// Local copies: hot loops must not read imported bindings (some module loaders, e.g. the test
+// runner's, turn each read into a property lookup on the module object).
+const EMPTY = MATERIAL_EMPTY;
+
 // Every change to the terrain bitmap goes through one sequenced event stream, so the
 // server and every client apply exactly the same mutations in exactly the same order.
 
@@ -41,18 +45,40 @@ export function applyTerrainEvent(bitmap: TerrainBitmap, ev: TerrainEvent, mater
   if (ev.kind === 'op') return applyOp(bitmap, ev.op, materials);
 
   const { x, y, w, h } = ev;
-  const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, w * h);
-  const { width, data } = bitmap;
-  for (let ly = 0; ly < h; ly++) {
-    const wy = y + ly;
-    if (wy < 0 || wy >= bitmap.height) continue;
-    for (let lx = 0; lx < w; lx++) {
-      const v = cells[ly * w + lx];
-      const wx = x + lx;
-      if (v === MATERIAL_EMPTY || wx < 0 || wx >= width) continue;
-      const i = wy * width + wx;
-      if (ev.kind === 'detach') data[i] = MATERIAL_EMPTY;
-      else if (data[i] === MATERIAL_EMPTY) data[i] = v;
+  return applyCells(bitmap, ev.kind, x, y, w, h, rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, w * h));
+}
+
+/**
+ * The body of a detach/merge event, from already-decoded cells. The server, which has the
+ * component's mask at hand, calls it directly instead of decoding the RLE it just encoded:
+ * same cells, same result as every client.
+ */
+export function applyCells(
+  bitmap: TerrainBitmap,
+  kind: 'detach' | 'merge',
+  x: number,
+  y: number,
+  w: number,
+  h: number,
+  cells: Uint8Array,
+): EventResult {
+  const { width, height, data } = bitmap;
+  const detach = kind === 'detach';
+  // Clip the rectangle to the map once, then run tight loops over each row.
+  const lx0 = Math.max(0, -x);
+  const lx1 = Math.min(w, width - x);
+  const ly0 = Math.max(0, -y);
+  const ly1 = Math.min(h, height - y);
+  for (let ly = ly0; ly < ly1; ly++) {
+    const src = ly * w;
+    const dst = (y + ly) * width + x;
+    if (detach) {
+      for (let lx = lx0; lx < lx1; lx++) if (cells[src + lx] !== EMPTY) data[dst + lx] = EMPTY;
+    } else {
+      for (let lx = lx0; lx < lx1; lx++) {
+        const v = cells[src + lx];
+        if (v !== EMPTY && data[dst + lx] === EMPTY) data[dst + lx] = v;
+      }
     }
   }
   const changed = { x0: x, y0: y, x1: x + w, y1: y + h };
@@ -108,14 +134,14 @@ export function rasterizeChunk(
   let count = 0;
   for (let wy = y0; wy < y1; wy++) {
     for (let wx = x0; wx < x1; wx++) {
-      if (bitmap.data[wy * bitmap.width + wx] !== MATERIAL_EMPTY) continue;
+      if (bitmap.data[wy * bitmap.width + wx] !== EMPTY) continue;
       const dx = wx + 0.5 - ox;
       const dy = wy + 0.5 - oy;
       const lx = Math.floor(dx * cos + dy * sin);
       const ly = Math.floor(-dx * sin + dy * cos);
       if (lx < 0 || ly < 0 || lx >= w || ly >= h) continue;
       const m = mask[ly * w + lx];
-      if (m === MATERIAL_EMPTY) continue;
+      if (m === EMPTY) continue;
       cells[(wy - y0) * pw + (wx - x0)] = m;
       count++;
     }
@@ -126,7 +152,7 @@ export function rasterizeChunk(
   for (let px = 0; px < pw; px++) {
     let lowest = -1;
     for (let py = ph - 1; py >= 0; py--) {
-      if (cells[py * pw + px] !== MATERIAL_EMPTY) {
+      if (cells[py * pw + px] !== EMPTY) {
         lowest = py;
         break;
       }
@@ -137,7 +163,7 @@ export function rasterizeChunk(
     for (let k = 1; k <= gluePx; k++) {
       const wy = y0 + lowest + k;
       if (wy >= bitmap.height) break;
-      if (bitmap.data[wy * bitmap.width + wx] !== MATERIAL_EMPTY) {
+      if (bitmap.data[wy * bitmap.width + wx] !== EMPTY) {
         for (let f = 1; f < k; f++) {
           cells[(lowest + f) * pw + px] = m;
           count++;

+ 20 - 5
src/shared/terrain/marchingSquares.ts

@@ -1,6 +1,10 @@
 import { MATERIAL_EMPTY } from '../constants';
 import type { TerrainBitmap } from './bitmap';
 
+// Local copies: hot loops must not read imported bindings (some module loaders, e.g. the test
+// runner's, turn each read into a property lookup on the module object).
+const EMPTY = MATERIAL_EMPTY;
+
 export interface Point {
   x: number;
   y: number;
@@ -50,7 +54,9 @@ function cellRange(start: number, end: number, size: number): [number, number] {
   return [start === 0 ? -1 : start, end >= size ? size : end];
 }
 
-// Scratch buffers reused across calls (grown on demand).
+// Scratch buffers reused across calls (grown on demand). `nextBuf` (-1) and `incomingBuf` (0)
+// are kept clean between calls: each call resets only the entries it wrote, instead of
+// clearing ~16k entries per chunk (most rebuilt chunks have few contours, or none).
 let solidBuf = new Uint8Array(0);
 let nextBuf = new Int32Array(0);
 let incomingBuf = new Uint8Array(0);
@@ -71,26 +77,29 @@ export function extractContours(bitmap: TerrainBitmap, x0: number, y0: number, x
   const sampleCount = sw * (rows + 1);
   if (solidBuf.length < sampleCount) solidBuf = new Uint8Array(sampleCount);
   const solid = solidBuf;
+  let solidCount = 0;
   for (let sy = 0; sy <= rows; sy++) {
     const y = cy0 + sy;
     const inY = y >= 0 && y < height;
     for (let sx = 0; sx <= cols; sx++) {
       const x = cx0 + sx;
-      solid[sy * sw + sx] = inY && x >= 0 && x < width && data[y * width + x] !== MATERIAL_EMPTY ? 1 : 0;
+      const v = inY && x >= 0 && x < width && data[y * width + x] !== EMPTY ? 1 : 0;
+      solid[sy * sw + sx] = v;
+      solidCount += v;
     }
   }
+  // All empty or all solid: no contour can cross this chunk.
+  if (solidCount === 0 || solidCount === sampleCount) return [];
 
   // 2. Segment graph on the half-pixel vertex grid: next[v] = following vertex or -1.
   const vw = 2 * cols + 1;
   const vertexCount = vw * (2 * rows + 1);
   if (nextBuf.length < vertexCount) {
-    nextBuf = new Int32Array(vertexCount);
+    nextBuf = new Int32Array(vertexCount).fill(-1);
     incomingBuf = new Uint8Array(vertexCount);
   }
   const next = nextBuf;
   const incoming = incomingBuf;
-  next.fill(-1, 0, vertexCount);
-  incoming.fill(0, 0, vertexCount);
   const starts: number[] = [];
 
   for (let cy = 0; cy < rows; cy++) {
@@ -127,5 +136,11 @@ export function extractContours(bitmap: TerrainBitmap, x0: number, y0: number, x
   };
   for (const s of starts) if (incoming[s] === 0) trace(s, false);
   for (const s of starts) if (incoming[s] !== 2) trace(s, true);
+  // Leave the buffers clean for the next call: every written entry is a segment start or end.
+  for (const s of starts) {
+    incoming[next[s]] = 0;
+    incoming[s] = 0;
+    next[s] = -1;
+  }
   return contours;
 }

+ 10 - 5
src/shared/terrain/ops.ts

@@ -2,6 +2,11 @@ import { DIRTY_MARGIN, MATERIAL_EMPTY, MATERIAL_PLATE } from '../constants';
 import type { MaterialRegistry } from '../materials';
 import type { Rect, TerrainBitmap } from './bitmap';
 
+// Local copies: hot loops must not read imported bindings (some module loaders, e.g. the test
+// runner's, turn each read into a property lookup on the module object).
+const EMPTY = MATERIAL_EMPTY;
+const PLATE = MATERIAL_PLATE;
+
 // Every op is applied with integer arithmetic only: the same op sequence must
 // produce a bit-identical bitmap on the server and on every client.
 
@@ -33,9 +38,9 @@ export function isValidOp(op: unknown): op is TerrainOp {
     case 'carve':
       return isInt(o.power, 0, 0xffff);
     case 'fill':
-      return isInt(o.material, 1, 255) && o.material !== MATERIAL_PLATE;
+      return isInt(o.material, 1, 255) && o.material !== PLATE;
     case 'convert':
-      return isInt(o.from, 0, 255) && isInt(o.to, 0, 255) && o.from !== MATERIAL_PLATE && o.to !== MATERIAL_PLATE;
+      return isInt(o.from, 0, 255) && isInt(o.to, 0, 255) && o.from !== PLATE && o.to !== PLATE;
     default:
       return false;
   }
@@ -66,10 +71,10 @@ export function applyOp(bitmap: TerrainBitmap, op: TerrainOp, materials: Materia
       let next = cur;
       switch (op.kind) {
         case 'carve':
-          if (cur !== MATERIAL_EMPTY && cur !== MATERIAL_PLATE && op.power >= materials.hardness[cur]) next = MATERIAL_EMPTY;
+          if (cur !== EMPTY && cur !== PLATE && op.power >= materials.hardness[cur]) next = EMPTY;
           break;
         case 'fill':
-          if (cur === MATERIAL_EMPTY) next = op.material;
+          if (cur === EMPTY) next = op.material;
           break;
         case 'convert':
           if (cur === op.from) next = op.to;
@@ -77,7 +82,7 @@ export function applyOp(bitmap: TerrainBitmap, op: TerrainOp, materials: Materia
       }
       if (next === cur) continue;
       data[i] = next;
-      if (cur !== MATERIAL_EMPTY) removedByMaterial.set(cur, (removedByMaterial.get(cur) ?? 0) + 1);
+      if (cur !== EMPTY) removedByMaterial.set(cur, (removedByMaterial.get(cur) ?? 0) + 1);
       if (x < minX) minX = x;
       if (y < minY) minY = y;
       if (x > maxX) maxX = x;

+ 10 - 1
tests/net.test.ts

@@ -16,6 +16,15 @@ const materials = realMaterials();
 const freshTerrain = (): TerrainBitmap => loadMap(ASSETS, 'gateau-etages', materials).terrain;
 const flush = (ms = 30): Promise<void> => new Promise((r) => setTimeout(r, ms));
 
+/** Waits until `cond` holds (messages arrive asynchronously; fixed delays are flaky under load). */
+async function until(cond: () => boolean, timeoutMs = 3000): Promise<void> {
+  const end = Date.now() + timeoutMs;
+  while (!cond()) {
+    if (Date.now() > end) throw new Error('until: timed out');
+    await flush(10);
+  }
+}
+
 /** A headless client that keeps its own bitmap in sync, like the renderer does. */
 class TestClient {
   readonly messages: ServerMessage[] = [];
@@ -121,7 +130,7 @@ describe('LAN server (M3)', () => {
     clients.push(a, b);
     await a.hello('Alice');
     await b.hello('Bob', 'castor');
-    await flush();
+    await until(() => a.last('lobby')?.players.length === 2);
     expect(a.last('lobby')).toMatchObject({ phase: 'lobby', countdown: null });
     expect(a.last('lobby')!.players.map((p) => p.name)).toEqual(['Alice', 'Bob']);