Skip to main content

Terrain System

The terrain system generates procedural 3D terrain using multi-layer Perlin noise with support for flat zones under stations and buildings. The system uses worker-based computation for height and normal calculations to maximize performance.
Location: packages/shared/src/systems/shared/world/TerrainSystem.ts

Architecture

Worker-Based Computation

The terrain system offloads height and normal calculations to a Web Worker for optimal performance:
// From TerrainSystem.ts
// Height and normal computation happens in TerrainWorker
// Main thread only recomputes for tiles overlapping flat zones
private async generateTileGeometry(tileX: number, tileZ: number): Promise<void> {
  // Request worker computation
  const workerResult = await this.terrainWorker.computeTile(tileX, tileZ);
  
  // Use worker heights + normals directly for tiles without flat zones
  if (!this.tileOverlapsFlatZone(tileX, tileZ)) {
    this.applyWorkerResults(workerResult);
    return;
  }
  
  // Fall back to main-thread recomputation only for flat zone tiles
  this.recomputeWithFlatZones(tileX, tileZ);
}
Performance Benefits:
  • Zero noise calls on main thread for non-flat-zone tiles
  • Parallel computation across multiple tiles
  • Normals computed in worker with centered finite differences
  • Main thread only handles flat zone blending

Height Parameter Synchronization

All terrain generation constants are centralized in TerrainHeightParams.ts to prevent parameter drift between main thread and worker:
// From TerrainHeightParams.ts
export interface NoiseLayerDef {
  scale: number;
  weight: number;
  octaves?: number;
  persistence?: number;
  lacunarity?: number;
  iterations?: number;  // For erosion noise
}

export const CONTINENT_LAYER: NoiseLayerDef = {
  scale: 0.0008,
  octaves: 5,
  persistence: 0.7,
  lacunarity: 2.0,
  weight: 0.35,
};

export const HILL_LAYER: NoiseLayerDef = {
  scale: 0.02,
  octaves: 4,
  persistence: 0.6,
  lacunarity: 2.2,
  weight: 0.25,
};

// Island configuration
export const ISLAND_RADIUS = 350;
export const ISLAND_FALLOFF = 100;
export const BASE_ELEVATION = 0.42;
export const OCEAN_FLOOR_HEIGHT = 0.05;

// Pond configuration
export const POND_RADIUS = 50;
export const POND_DEPTH = 0.55;
export const POND_CENTER_X = -80;
export const POND_CENTER_Z = 60;

// Coastline noise for irregular shorelines
export const COASTLINE_CIRCLE_SAMPLE_RADIUS = 2;
export const COAST_LARGE = {
  octaves: 3,
  persistence: 0.5,
  lacunarity: 2.0,
  weight: 0.2,
};

// Mountain boost
export const MOUNTAIN_BOOST_MAX_NORM_DIST = 2.5;
export const MOUNTAIN_BOOST_GAUSSIAN_COEFF = 0.3;

// Worker code generation
export function buildGetBaseHeightAtJS(): string {
  // Injects all constants into worker string
  return `function getBaseHeightAt(worldX, worldZ) { ... }`;
}
Implementation:
  • TerrainSystem imports params as TypeScript constants
  • TerrainWorker receives params via buildGetBaseHeightAtJS() injection
  • Single source of truth prevents parameter drift
  • Worker and main thread produce identical heights
  • All numeric constants baked into worker code at runtime

Worker Height Computation

The worker computes fully correct heights including shoreline adjustments:
// From TerrainWorker (injected into worker string)
function getBaseHeightAt(x: number, z: number): number {
  // 1. Multi-layer Perlin noise
  let height = 0;
  height += perlin(x * continentScale, z * continentScale) * continentWeight;
  height += perlin(x * ridgeScale, z * ridgeScale) * ridgeWeight;
  height += perlin(x * hillScale, z * hillScale) * hillWeight;
  height += perlin(x * erosionScale, z * erosionScale) * erosionWeight;
  height += perlin(x * detailScale, z * detailScale) * detailWeight;
  
  // 2. Island mask
  const islandMask = calculateIslandMask(x, z);
  height *= islandMask;
  
  // 3. Pond depression
  const pondMask = calculatePondMask(x, z);
  height -= pondMask;
  
  // 4. Shoreline adjustment
  const slope = calculateBaseSlopeAt(x, z);
  height = adjustHeightForShoreline(height, slope);
  
  // 5. Mountain boost
  if (height > mountainBoostThreshold) {
    height = mountainBoostThreshold + 
      (height - mountainBoostThreshold) * mountainBoostMultiplier;
  }
  
  return height * MAX_HEIGHT;
}

Normal Computation in Worker

Normals are computed in the worker using centered finite differences on an overflow grid:
// Worker computes (resolution+2)² grid with 1-tile border
// Allows centered finite differences at all vertices
const gridSize = resolution + 2;
const heights = new Float32Array(gridSize * gridSize);

// Sample heights with overflow
for (let z = -1; z <= resolution; z++) {
  for (let x = -1; x <= resolution; x++) {
    const worldX = tileWorldX + x * step;
    const worldZ = tileWorldZ + z * step;
    heights[(z + 1) * gridSize + (x + 1)] = getBaseHeightAt(worldX, worldZ);
  }
}

// Compute normals with centered finite differences
for (let z = 0; z < resolution; z++) {
  for (let x = 0; x < resolution; x++) {
    const hL = heights[(z + 1) * gridSize + x];
    const hR = heights[(z + 1) * gridSize + (x + 2)];
    const hD = heights[z * gridSize + (x + 1)];
    const hU = heights[(z + 2) * gridSize + (x + 1)];
    
    const nx = (hL - hR) / (2 * step);
    const nz = (hD - hU) / (2 * step);
    const ny = 1.0;
    
    // Normalize
    const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
    normals[idx] = nx / len;
    normals[idx + 1] = ny / len;
    normals[idx + 2] = nz / len;
  }
}
Benefits:
  • Accurate normals at tile boundaries (no edge artifacts)
  • Centered differences produce smoother lighting
  • Worker handles all computation (zero main thread cost)

World Specs

// From TerrainSystem.ts CONFIG
const CONFIG = {
  TILE_SIZE: 100,           // 100m × 100m tiles
  WORLD_SIZE: 100,          // 100×100 grid = 10km × 10km world
  TILE_RESOLUTION: 64,      // 64×64 vertices per tile
  MAX_HEIGHT: 50,           // 50m max height variation
  WATER_THRESHOLD: 9.0,     // Water appears below 9m
  CAMERA_FAR: 400,          // Draw distance
};

Noise Layers

Terrain height is generated from multiple Perlin noise layers defined in TerrainHeightParams.ts:
LayerScaleWeightOctavesPersistenceLacunarityPurpose
Continent0.00080.3550.72.0Large-scale landmasses
Ridge0.0030.15---Mountain ridges
Hill0.020.2540.62.2Rolling hills
Erosion0.0050.13 iterations--Weathering patterns
Detail0.040.0820.32.5Local bumps and variation
Combined height is normalized to [0, 1], raised to power curve (1.1), then scaled by MAX_HEIGHT (50m). Coastline Variation: Natural irregular shorelines created by sampling noise on a circle around the island:
  • Large-scale: 3 octaves, weight 0.2
  • Medium-scale: 3x frequency, 2 octaves, weight 0.08
  • Small-scale: 8x frequency, weight 0.02
  • Varies island radius by ±20% for organic coastlines

Flat Zones

Flat zones create level terrain under stations and buildings with smooth blending to procedural terrain.

How It Works

Height Calculation Priority:
  1. Flat zones checked before procedural terrain
  2. Core Flat Area: Inside the zone, terrain returns exact height value
  3. Blend Area: Within blendRadius of zone edge, smoothstep interpolation blends to procedural terrain
  4. Spatial Indexing: Terrain tiles (100m) used for O(1) lookup
  5. Manifest-Driven: Stations with flattenGround: true automatically create flat zones
Blend Formula:
// Smoothstep interpolation from flat to procedural
const t = smoothstep(0, blendRadius, distanceFromEdge);
const blendedHeight = flatHeight * (1 - t) + proceduralHeight * t;

Flat Zone Registration

When a station spawns, TerrainSystem registers a flat zone: Dimensions calculated from:
  • Station footprint (from model bounds)
  • flattenPadding (extra space around footprint)
  • flattenBlendRadius (smooth transition zone)
Height sampled from:
  • Procedural terrain at station center
  • Ensures flat zone matches surrounding terrain elevation
// From StationSpawnerSystem.ts
terrainSystem.registerFlatZone({
  id: `station_${stationId}`,
  centerX: position.x,
  centerZ: position.z,
  width: footprint.width + flattenPadding * 2,
  depth: footprint.depth + flattenPadding * 2,
  height: terrainSystem.getHeightAt(position.x, position.z),
  blendRadius: flattenBlendRadius,
});

Height Calculation

When terrain height is requested, flat zones are checked first:
getHeightAt(worldX: number, worldZ: number): number {
  // 1. Check flat zones (O(1) via spatial index)
  const flatHeight = getFlatZoneHeight(worldX, worldZ);
  if (flatHeight !== null) {
    return flatHeight;
  }
  
  // 2. Fall back to procedural height
  return getProceduralHeightWithBoost(worldX, worldZ);
}

TerrainSystem API

// Register a flat zone (for dynamic structures)
terrainSystem.registerFlatZone({
  id: "player_house_1",
  centerX: 50.0,
  centerZ: 50.0,
  width: 10.0,
  depth: 10.0,
  height: 42.5,
  blendRadius: 1.0,
});

// Remove a flat zone
terrainSystem.unregisterFlatZone("player_house_1");

// Query flat zone at position
const zone = terrainSystem.getFlatZoneAt(worldX, worldZ);
if (zone) {
  console.log(`Standing on flat zone: ${zone.id}`);
}

Duel Arena Floor Fix (commits b8f56e81, 7a60135e, 51453da)

Players and agents were sinking ~0.4m into duel arena floors because flat zones were not being registered with the terrain system. This caused getHeightAt() to return raw procedural terrain height instead of floor-level height, and also allowed grass to grow through floor surfaces. Problem:
  • Flat zones were not registered for duel arena floors
  • getHeightAt() returned procedural terrain height (~0.4m below arena floors)
  • Players/agents spawned at procedural height, sinking into visual floor meshes
  • Grass system used procedural heights, rendering grass through floors
  • Terrain mesh rendered at procedural height, creating z-fighting with floor geometry
Solution: Flat zones are now registered programmatically from DuelArenaVisualsSystem for all 8 floor areas (6 arenas + lobby + hospital):
// From DuelArenaVisualsSystem.ts
// Register flat zones during system initialization
private registerArenaFlatZones(): void {
  const FLAT_ZONE_HEIGHT_OFFSET = 0.4; // Where players stand above procedural terrain
  const BLEND_RADIUS = 1.0;
  const CARVE_INSET = 1.0;

  // Register flat zones for all 6 arenas
  for (let i = 0; i < ARENA_COUNT; i++) {
    const row = Math.floor(i / 2);
    const col = i % 2;
    const centerX = ARENA_BASE_X + col * (ARENA_WIDTH + ARENA_GAP) + ARENA_WIDTH / 2;
    const centerZ = ARENA_BASE_Z + row * (ARENA_LENGTH + ARENA_GAP) + ARENA_LENGTH / 2;

    const proceduralHeight = this.getProceduralTerrainHeight(centerX, centerZ);
    const zone: FlatZone = {
      id: `duel_arena_floor_${i + 1}`,
      centerX,
      centerZ,
      width: ARENA_WIDTH,
      depth: ARENA_LENGTH,
      height: proceduralHeight + FLAT_ZONE_HEIGHT_OFFSET,
      blendRadius: BLEND_RADIUS,
      carveInset: CARVE_INSET,
    };

    this.terrainSystem.registerFlatZone(zone);
    this.flatZoneIds.push(zone.id);
  }

  // Register lobby and hospital floors (similar logic)
  // ...
}
Key Implementation Details:
  1. Procedural Height Sampling: Uses getProceduralTerrainHeight() to get the raw terrain height at each arena center, then adds 0.4m offset for player standing height
  2. Flat Zone Parameters:
    • height: Procedural terrain height + 0.4m (where players stand)
    • blendRadius: 1.0m smooth transition to surrounding terrain
    • carveInset: 1.0m inset from zone edges to preserve blend padding
  3. Registration Timing: Flat zones are registered in DuelArenaVisualsSystem.start() after terrain system is initialized but before arena meshes are created
  4. Cleanup: Flat zones are unregistered in DuelArenaVisualsSystem.destroy() to prevent memory leaks
Affected Areas:
  • 6 duel arenas (20m × 24m each)
  • Lobby floor (40m × 25m)
  • Hospital floor (30m × 25m)
Impact:
  • Terrain height queries now return correct floor-level values (procedural + 0.4m)
  • Players/agents spawn at proper height (no sinking)
  • Grass system respects flat zones (no grass through floors)
  • Terrain mesh is carved under floor areas to prevent overdraw
  • Visual floor meshes positioned 2cm above terrain mesh to prevent z-fighting
  • Terrain tiles are automatically regenerated when flat zones are registered after initial tile generation
Terrain Mesh Regeneration: When flat zones are registered after terrain tiles have already been generated, the terrain system automatically regenerates affected tiles to reflect the flat zone heights:
// From TerrainSystem.ts
// Regenerate any existing terrain tiles to apply flat zone heights
if (tilesToRegenerate.length > 0) {
  console.log(
    `[TerrainSystem] Regenerating ${tilesToRegenerate.length} terrain tiles for flat zone "${zone.id}"`
  );
  for (const tile of filteredTiles) {
    this.queueTerrainTileRegeneration(tile.x, tile.z, \"flat_zone\");
  }
}
This ensures terrain meshes always reflect flat zone heights, even when buildings or arenas are added after terrain initialization. Console Output:
[DuelArenaVisualsSystem] Registered 8 flat zones (6 arenas + lobby + hospital)
[DuelArenaVisualsSystem] Created floor 1 at (70.0, 0.67, 92.0) - terrain=0.27
[TerrainSystem] Flat zone stats: 8 zones registered, 24 tile keys in spatial index
[TerrainSystem] Regenerating 2 terrain tiles for flat zone "duel_arena_floor_1"

Console Logging

TerrainSystem logs flat zone activity:
[TerrainSystem] Registered flat zone "station_bank_lumbridge_1" -> tile keys: [0_-1, 0_0]
[TerrainSystem] FLAT ZONE HIT: "station_bank_lumbridge_1" at (2.5, -24.8) -> height=42.15
[TerrainSystem] Flat zone stats: 5 zones registered, 12 tile keys in spatial index, 1247 height lookups used flat zones
[TerrainSystem] Regenerating 2 terrain tiles for flat zone "duel_arena_0"

Performance Characteristics

Worker-Based Computation:
  • Height calculation: ~0ms on main thread (worker handles all noise)
  • Normal calculation: ~0ms on main thread (worker computes with overflow grid)
  • Tile generation: ~5-10ms total (worker + transfer + geometry build)
  • Flat zone tiles: ~15-20ms (requires main thread recomputation)
Spatial Indexing:
  • Flat zone lookup: O(1) via tile-based spatial index
  • Typical world: 5-10 flat zones, 10-20 tile keys
  • Negligible memory overhead (~1KB per zone)