Skip to main content

Overview

The terrain system generates procedural 3D terrain using Perlin noise with support for flat zones under stations and buildings. Location: packages/shared/src/systems/shared/world/TerrainSystem.ts

Terrain Configuration

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:
LayerScaleWeightPurpose
Continent0.0020.35Large-scale landmasses
Ridge0.0080.15Mountain ridges
Hill0.020.25Rolling hills
Erosion0.040.10Weathering patterns
Detail0.10.08Local bumps and variation
Combined height is normalized to [0, 1] then scaled by MAX_HEIGHT.

Flat Zone System

Purpose

Flat zones create level ground under stations (banks, furnaces, anvils) for professional world building. Without flattening, stations would sit on bumpy terrain at odd angles.

Configuration

Flat zones are defined in station manifests (stations.json):
{
  "type": "bank",
  "model": "bank.glb",
  "flattenGround": true,
  "flattenPadding": 0.3,
  "flattenBlendRadius": 0.5
}
PropertyTypeDefaultDescription
flattenGroundbooleanfalseEnable terrain flattening
flattenPaddingnumber0.3Extra meters around footprint to flatten
flattenBlendRadiusnumber0.5Smooth transition distance to procedural terrain

How It Works

1. Flat Zone Registration

When a station spawns, TerrainSystem registers a flat zone:
interface FlatZone {
  id: string;              // "station_bank_lumbridge_1"
  centerX: number;         // World X coordinate
  centerZ: number;         // World Z coordinate
  width: number;           // Flat area width (meters)
  depth: number;           // Flat area depth (meters)
  height: number;          // Target flat height (meters)
  blendRadius: number;     // Transition distance (meters)
}
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

2. Spatial Indexing

Flat zones are indexed by terrain tiles (100m) for O(1) lookup:
// Spatial index: terrain tile → flat zones
private flatZonesByTile = new Map<string, FlatZone[]>();

// Register zone
registerFlatZone(zone: FlatZone): void {
  // Calculate affected terrain tiles
  const minTileX = Math.floor((zone.centerX - totalRadius) / TILE_SIZE);
  const maxTileX = Math.floor((zone.centerX + totalRadius) / TILE_SIZE);
  // ... same for Z
  
  // Add to each overlapping tile
  for (let tx = minTileX; tx <= maxTileX; tx++) {
    for (let tz = minTileZ; tz <= maxTileZ; tz++) {
      const key = `${tx}_${tz}`;
      flatZonesByTile.get(key).push(zone);
    }
  }
}

3. 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);
}

4. Smooth Blending

Flat zones use smoothstep interpolation for natural transitions:
// Inside flat zone core area
if (dx <= halfWidth && dz <= halfDepth) {
  return zone.height; // Exact flat height
}

// Inside blend area
if (dx <= blendHalfWidth && dz <= blendHalfDepth) {
  const proceduralHeight = getProceduralHeightWithBoost(worldX, worldZ);
  
  // Calculate blend factor (0 at flat edge, 1 at blend edge)
  const blendX = dx > halfWidth ? (dx - halfWidth) / blendRadius : 0;
  const blendZ = dz > halfDepth ? (dz - halfDepth) / blendRadius : 0;
  const blend = Math.max(blendX, blendZ);
  
  // Smoothstep: t² × (3 - 2t)
  const t = blend * blend * (3 - 2 * blend);
  
  // Interpolate from flat to procedural
  return zone.height + (proceduralHeight - zone.height) * t;
}

API Methods

interface TerrainSystem {
  // Register a flat zone (for dynamic structures)
  registerFlatZone(zone: FlatZone): void;
  
  // Remove a flat zone by ID
  unregisterFlatZone(id: string): void;
  
  // Check if position is in a flat zone
  getFlatZoneAt(worldX: number, worldZ: number): FlatZone | null;
}

Station Manifest Integration

StationDataProvider

Location: packages/shared/src/data/StationDataProvider.ts Loads station configurations including flat zone settings:
interface StationManifestEntry {
  type: string;
  model: string;
  scale?: number;
  modelYOffset?: number;
  examine: string;
  footprint?: FootprintSpec;
  
  // Flat zone settings
  flattenGround?: boolean;
  flattenPadding?: number;
  flattenBlendRadius?: number;
}

Automatic Flat Zone Creation

When StationSpawnerSystem spawns a station:
  1. Check if flattenGround: true in station manifest
  2. Get station footprint from model bounds
  3. Calculate flat zone dimensions (footprint + padding)
  4. Sample terrain height at station center
  5. Register flat zone with TerrainSystem
  6. Terrain mesh updates automatically

Example Station Config

{
  "type": "bank",
  "model": "bank.glb",
  "scale": 1.0,
  "modelYOffset": 0.0,
  "examine": "A secure place to store your items.",
  "flattenGround": true,
  "flattenPadding": 0.5,
  "flattenBlendRadius": 1.0
}
This creates:
  • Flat zone centered on bank position
  • Width/depth = bank footprint + 1m (0.5m padding on each side)
  • 1m blend radius for smooth transition
  • Height = procedural terrain at bank center

Performance

Spatial Index Efficiency

  • Lookup: O(1) via terrain tile key
  • Memory: Only stores zones, not per-vertex data
  • Updates: Flat zones registered once at startup
  • Runtime: Zero allocations in height lookup hot path

Terrain Tile Caching

  • Terrain tiles (100m) cached after generation
  • Flat zones don’t invalidate cache
  • Height lookups check flat zones before cache

Biome Integration

Flat zones work with all biome types:
  • Grasslands: Flat zones blend with gentle hills
  • Forests: Stations clear vegetation in flat area
  • Mountains: Flat zones create plateaus on slopes
  • Deserts: Flat zones blend with dunes

Debugging

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

Visual Debugging

Flat zones are visible in-game:
  • Bumpy terrain contrasts with flat station areas
  • Smooth blend transitions prevent sharp edges
  • Stations sit level on ground

Future Enhancements

Potential improvements for dynamic flat zones:
  • Player-Placed Structures: Flatten ground under player buildings
  • Dynamic Registration: Add/remove flat zones at runtime
  • Circular Zones: Support circular flat areas (not just rectangular)
  • Height Adjustment: Raise/lower flat zones relative to terrain
  • Vegetation Clearing: Auto-clear trees/rocks in flat zones