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:
| Layer | Scale | Weight | Octaves | Persistence | Lacunarity | Purpose |
|---|
| Continent | 0.0008 | 0.35 | 5 | 0.7 | 2.0 | Large-scale landmasses |
| Ridge | 0.003 | 0.15 | - | - | - | Mountain ridges |
| Hill | 0.02 | 0.25 | 4 | 0.6 | 2.2 | Rolling hills |
| Erosion | 0.005 | 0.1 | 3 iterations | - | - | Weathering patterns |
| Detail | 0.04 | 0.08 | 2 | 0.3 | 2.5 | Local 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:
- Flat zones checked before procedural terrain
- Core Flat Area: Inside the zone, terrain returns exact height value
- Blend Area: Within
blendRadius of zone edge, smoothstep interpolation blends to procedural terrain
- Spatial Indexing: Terrain tiles (100m) used for O(1) lookup
- 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:
-
Procedural Height Sampling: Uses
getProceduralTerrainHeight() to get the raw terrain height at each arena center, then adds 0.4m offset for player standing height
-
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
-
Registration Timing: Flat zones are registered in
DuelArenaVisualsSystem.start() after terrain system is initialized but before arena meshes are created
-
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"
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)