> ## Documentation Index
> Fetch the complete documentation index at: https://hyperscape-ai.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Tile Movement System

> OSRS-accurate tile-based movement and pathfinding

# Tile Movement System

Hyperscape uses a **discrete tile-based movement system** inspired by RuneScape. The world is divided into tiles, and entities move one tile at a time in sync with server ticks.

<Info>
  The tile system lives in `packages/shared/src/systems/shared/movement/TileSystem.ts`.
</Info>

## Core Constants

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From TileSystem.ts
export const TILE_SIZE = 1.0;           // 1 world unit = 1 tile
export const TICK_DURATION_MS = 600;    // 0.6 seconds per server tick
export const TILES_PER_TICK_WALK = 2;   // Walking: 2 tiles per tick (2x OSRS)
export const TILES_PER_TICK_RUN = 4;    // Running: 4 tiles per tick (2x OSRS)
export const MAX_PATH_LENGTH = 25;      // Maximum tiles in a path
export const PATHFIND_RADIUS = 128;     // BFS search radius in tiles
```

<Note>
  Hyperscape uses **2x OSRS speed** for a snappier modern feel while keeping the tick-based system. OSRS uses 1 tile/tick walk, 2 tiles/tick run.
</Note>

### Agility XP from Movement

Movement grants Agility XP at a rate of **1 XP per 2 tiles traveled**:

* Walking: 2 tiles/tick = \~100 XP/minute
* Running: 4 tiles/tick = \~200 XP/minute
* XP granted in batches of 50 XP every 100 tiles (prevents visual spam)
* Death penalty: Accumulated tile progress is lost (max \~50 XP worth)

<Info>
  Agility XP is tracked server-side in `TileMovementManager` and granted via the `SKILLS_XP_GAINED` event. See [Skills System](/wiki/game-systems/skills) for details on agility's stamina regeneration bonus.
</Info>

***

## Tile Coordinates

Tiles use integer coordinates on the X-Z plane. Height (Y) comes from terrain.

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Tile coordinate (always integers)
export interface TileCoord {
  x: number; // Integer tile X
  z: number; // Integer tile Z
}
```

### World ↔ Tile Conversion

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Convert world coordinates to tile coordinates
export function worldToTile(worldX: number, worldZ: number): TileCoord {
  return {
    x: Math.floor(worldX / TILE_SIZE),
    z: Math.floor(worldZ / TILE_SIZE),
  };
}

// Convert tile to world (tile center)
export function tileToWorld(tile: TileCoord): { x: number; y: number; z: number } {
  return {
    x: (tile.x + 0.5) * TILE_SIZE,
    y: 0, // Y set from terrain height
    z: (tile.z + 0.5) * TILE_SIZE,
  };
}

// Snap position to tile center
export function snapToTileCenter(position: Position3D): Position3D {
  return {
    x: Math.floor(position.x / TILE_SIZE) * TILE_SIZE + 0.5 * TILE_SIZE,
    y: position.y,
    z: Math.floor(position.z / TILE_SIZE) * TILE_SIZE + 0.5 * TILE_SIZE,
  };
}
```

***

## Movement State

Each entity with movement has a `TileMovementState`:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
export interface TileMovementState {
  currentTile: TileCoord;      // Current position
  path: TileCoord[];           // Queue of tiles to walk through
  pathIndex: number;           // Current position in path
  isRunning: boolean;          // Walk (2 tiles/tick) vs Run (4 tiles/tick)
  moveSeq: number;             // Incremented on each new path
  previousTile: TileCoord | null; // Tile at START of current tick
}
```

### Previous Tile (OSRS Follow Mechanic)

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// OSRS-ACCURATE: Following a player means walking to their PREVIOUS tile,
// creating the characteristic 1-tick trailing effect.
previousTile: TileCoord | null;
```

***

## Distance Functions

### Manhattan Distance

Used for simple distance checks:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
export function tileManhattanDistance(a: TileCoord, b: TileCoord): number {
  return Math.abs(a.x - b.x) + Math.abs(a.z - b.z);
}
```

### Chebyshev Distance

The actual "tile distance" for diagonal movement:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
export function tileChebyshevDistance(a: TileCoord, b: TileCoord): number {
  return Math.max(Math.abs(a.x - b.x), Math.abs(a.z - b.z));
}
```

***

## Adjacency Functions

### 8-Direction Adjacency

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Check if tiles are adjacent (including diagonals)
export function tilesAdjacent(a: TileCoord, b: TileCoord): boolean {
  const dx = Math.abs(a.x - b.x);
  const dz = Math.abs(a.z - b.z);
  return dx <= 1 && dz <= 1 && (dx > 0 || dz > 0);
}

// Get all 8 adjacent tiles (RuneScape order: W, E, S, N, SW, SE, NW, NE)
export function getAdjacentTiles(tile: TileCoord): TileCoord[] {
  return [
    { x: tile.x - 1, z: tile.z },     // West
    { x: tile.x + 1, z: tile.z },     // East
    { x: tile.x, z: tile.z - 1 },     // South
    { x: tile.x, z: tile.z + 1 },     // North
    { x: tile.x - 1, z: tile.z - 1 }, // Southwest
    { x: tile.x + 1, z: tile.z - 1 }, // Southeast
    { x: tile.x - 1, z: tile.z + 1 }, // Northwest
    { x: tile.x + 1, z: tile.z + 1 }, // Northeast
  ];
}
```

### Cardinal-Only Adjacency

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Check if tiles are cardinally adjacent (N/S/E/W only)
export function tilesCardinallyAdjacent(a: TileCoord, b: TileCoord): boolean {
  const dx = Math.abs(a.x - b.x);
  const dz = Math.abs(a.z - b.z);
  return (dx === 1 && dz === 0) || (dx === 0 && dz === 1);
}

// Get cardinal tiles only
export const CARDINAL_DIRECTIONS = [
  { x: 0, z: 1 },  // North
  { x: 1, z: 0 },  // East
  { x: 0, z: -1 }, // South
  { x: -1, z: 0 }, // West
];
```

***

## Combat Positioning

### Melee Range

<Warning>
  **OSRS Accuracy**: Standard melee (range 1) requires **cardinal adjacency** only. You cannot attack diagonally without a halberd (range 2).
</Warning>

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
export function tilesWithinMeleeRange(
  attacker: TileCoord,
  target: TileCoord,
  meleeRange: number,
): boolean {
  const dx = Math.abs(attacker.x - target.x);
  const dz = Math.abs(attacker.z - target.z);

  // Range 1: CARDINAL ONLY (standard melee)
  if (meleeRange === 1) {
    return (dx === 1 && dz === 0) || (dx === 0 && dz === 1);
  }

  // Range 2+: Allow diagonal (halberd, spear)
  const chebyshevDistance = Math.max(dx, dz);
  return chebyshevDistance <= meleeRange && chebyshevDistance > 0;
}
```

### Best Combat Tile

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Find the best tile to stand on for melee combat
export function getBestMeleeTile(
  target: TileCoord,
  attacker: TileCoord,
  meleeRange: number = 1,
  isWalkable?: (tile: TileCoord) => boolean,
): TileCoord | null {
  // If already in range, stay put
  if (tilesWithinMeleeRange(attacker, target, meleeRange)) {
    return attacker;
  }

  // For range 1: CARDINAL ONLY
  if (meleeRange === 1) {
    const cardinalTiles = [
      { x: target.x - 1, z: target.z }, // West
      { x: target.x + 1, z: target.z }, // East
      { x: target.x, z: target.z - 1 }, // South
      { x: target.x, z: target.z + 1 }, // North
    ];

    // Find closest walkable tile
    return cardinalTiles
      .filter((tile) => !isWalkable || isWalkable(tile))
      .sort((a, b) =>
        tileChebyshevDistance(a, attacker) - tileChebyshevDistance(b, attacker)
      )[0] ?? null;
  }

  // For range 2+: All tiles within Chebyshev distance
  // ... implementation for halberd range
}
```

***

## NPC Step-Out

When an NPC is on the same tile as its target, it must step out before attacking.

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// OSRS-accurate: Pick random cardinal direction for step-out
export function getBestStepOutTile(
  currentTile: TileCoord,
  occupancy: IEntityOccupancy,
  entityId: EntityID,
  isWalkable: (tile: TileCoord) => boolean,
  rng: { nextInt: (max: number) => number },
): TileCoord | null {
  // Shuffle cardinal directions (OSRS randomness)
  const shuffledCardinals = shuffleArray([...CARDINAL_DIRECTIONS], rng);

  for (const dir of shuffledCardinals) {
    const tile = { x: currentTile.x + dir.x, z: currentTile.z + dir.z };

    // Check terrain walkability
    if (!isWalkable(tile)) continue;

    // Check entity occupancy (exclude self)
    if (occupancy.isBlocked(tile, entityId)) continue;

    return tile;
  }

  return null; // All tiles blocked
}
```

***

## Resource Interaction

### Multi-Tile Resources

Large resources (like trees) span multiple tiles. Players can interact from any adjacent tile.

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Get all adjacent tiles for a multi-tile resource
export function getResourceAdjacentTiles(
  anchorTile: TileCoord,  // SW corner
  footprintX: number,     // Width in tiles
  footprintZ: number,     // Depth in tiles
): TileCoord[] {
  const adjacent: TileCoord[] = [];

  // North edge
  for (let dx = 0; dx < footprintX; dx++) {
    adjacent.push({ x: anchorTile.x + dx, z: anchorTile.z + footprintZ });
  }

  // South edge
  for (let dx = 0; dx < footprintX; dx++) {
    adjacent.push({ x: anchorTile.x + dx, z: anchorTile.z - 1 });
  }

  // East edge
  for (let dz = 0; dz < footprintZ; dz++) {
    adjacent.push({ x: anchorTile.x + footprintX, z: anchorTile.z + dz });
  }

  // West edge
  for (let dz = 0; dz < footprintZ; dz++) {
    adjacent.push({ x: anchorTile.x - 1, z: anchorTile.z + dz });
  }

  // Corner tiles
  adjacent.push({ x: anchorTile.x - 1, z: anchorTile.z - 1 }); // SW
  adjacent.push({ x: anchorTile.x + footprintX, z: anchorTile.z - 1 }); // SE
  adjacent.push({ x: anchorTile.x - 1, z: anchorTile.z + footprintZ }); // NW
  adjacent.push({ x: anchorTile.x + footprintX, z: anchorTile.z + footprintZ }); // NE

  return adjacent;
}
```

### Cardinal-Only Interaction

For consistent face direction during resource gathering:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Cardinal directions only (no diagonals)
export function getCardinalAdjacentTiles(
  anchorTile: TileCoord,
  footprintX: number,
  footprintZ: number,
): TileCoord[] {
  const adjacent: TileCoord[] = [];

  // Only N/S/E/W edges, no corners
  // ... (north, south, east, west edges)

  return adjacent;
}

// Determine face direction based on position
export function getCardinalFaceDirection(
  playerTile: TileCoord,
  resourceAnchor: TileCoord,
  footprintX: number,
  footprintZ: number,
): CardinalDirection | null {
  // Player north of resource → face South
  // Player east of resource → face West
  // Player south of resource → face North
  // Player west of resource → face East
}
```

***

## Collision System

Hyperscape uses a unified **CollisionMatrix** for OSRS-accurate tile-based collision. The system handles static objects (trees, rocks, stations), entities (players, NPCs), and terrain (water, slopes).

### CollisionMatrix Architecture

The collision system uses zone-based storage for optimal memory and performance:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From CollisionMatrix.ts
export interface ICollisionMatrix {
  // Get collision flags for a tile
  getFlags(tileX: number, tileZ: number): number;
  
  // Add/remove flags (bitwise operations)
  addFlags(tileX: number, tileZ: number, flags: number): void;
  removeFlags(tileX: number, tileZ: number, flags: number): void;
  
  // Check if tile has specific flags
  hasFlags(tileX: number, tileZ: number, flags: number): boolean;
  
  // Check if movement is blocked
  isBlocked(fromX: number, fromZ: number, toX: number, toZ: number): boolean;
  isWalkable(tileX: number, tileZ: number): boolean;
}
```

**Zone-Based Storage:**

* World divided into 8×8 tile zones
* Each zone = `Int32Array[64]` = 256 bytes
* 1000×1000 tile world = \~4MB memory
* Lazy allocation (zones created on first write)

### Collision Flags

Tiles use bitmask flags for efficient collision queries:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From CollisionFlags.ts
export const CollisionFlag = {
  // Static objects
  BLOCKED: 0x00200000,        // Trees, rocks, stations
  WATER: 0x00800000,          // Water tiles
  STEEP_SLOPE: 0x01000000,    // Impassable terrain
  
  // Entity occupancy
  OCCUPIED_PLAYER: 0x00000100,
  OCCUPIED_NPC: 0x00000200,
  
  // Directional walls (for future dungeons)
  WALL_NORTH: 0x00000002,
  WALL_EAST: 0x00000008,
  WALL_SOUTH: 0x00000020,
  WALL_WEST: 0x00000080,
  // ... diagonal walls
} as const;

// Combined masks for common queries
export const CollisionMask = {
  BLOCKS_WALK: BLOCKED | WATER | STEEP_SLOPE,
  OCCUPIED: OCCUPIED_PLAYER | OCCUPIED_NPC,
  BLOCKS_MOVEMENT: BLOCKS_WALK | OCCUPIED,
} as const;
```

### Usage Examples

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Check if tile is walkable
if (world.collision.isWalkable(tileX, tileZ)) {
  // Safe to move here
}

// Check for static objects only (ignore entities)
if (world.collision.hasFlags(tileX, tileZ, CollisionMask.BLOCKS_WALK)) {
  // Tree, rock, or station blocking
}

// Check if movement is blocked (includes walls)
if (world.collision.isBlocked(fromX, fromZ, toX, toZ)) {
  // Cannot move from -> to
}
```

### Multi-Tile Footprints

Stations and large resources can occupy multiple tiles:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// 2x2 furnace centered at (10, 10) occupies:
// (9,9), (10,9), (9,10), (10,10)

// Players can interact from any adjacent tile
const inRange = tilesWithinRangeOfFootprint(
  playerTile,
  stationCenterTile,
  2, // width
  2, // depth
  1  // range
);
```

<Info>
  Footprints are **centered** on the entity position, not corner-based. A 2×2 station at (10,10) occupies tiles (9,9) through (10,10).
</Info>

## Entity Occupancy

The `EntityOccupancyMap` tracks which tiles are occupied by entities and delegates to `CollisionMatrix` for unified storage:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From EntityOccupancyMap.ts
export interface IEntityOccupancy {
  // Check if tile is blocked (optionally excluding an entity)
  isBlocked(tile: TileCoord, excludeEntityId?: EntityID): boolean;

  // Get entity at tile
  getEntityAt(tile: TileCoord): EntityID | null;

  // Update entity position (atomic with collision updates)
  moveEntity(entityId: EntityID, fromTile: TileCoord, toTile: TileCoord): void;

  // Add/remove entities
  addEntity(entityId: EntityID, tile: TileCoord): void;
  removeEntity(entityId: EntityID): void;
}
```

<Note>
  Entity moves are **atomic** - old tiles are freed and new tiles occupied in a single operation. Delta optimization ensures only changed tiles are updated.
</Note>

### Zero-Allocation Helpers

For performance in hot paths, use pre-allocated buffers:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Zero-allocation tile conversion
export function worldToTileInto(
  worldX: number,
  worldZ: number,
  out: TileCoord,
): void {
  out.x = Math.floor(worldX / TILE_SIZE);
  out.z = Math.floor(worldZ / TILE_SIZE);
}

// Pre-allocated buffer for melee tiles
const _cardinalMeleeTiles: TileCoord[] = [
  { x: 0, z: 0 },
  { x: 0, z: 0 },
  { x: 0, z: 0 },
  { x: 0, z: 0 },
];

export function getCardinalMeleeTilesInto(
  targetTile: TileCoord,
  buffer: TileCoord[],
): number {
  buffer[0].x = targetTile.x;
  buffer[0].z = targetTile.z - 1; // South
  buffer[1].x = targetTile.x;
  buffer[1].z = targetTile.z + 1; // North
  buffer[2].x = targetTile.x - 1;
  buffer[2].z = targetTile.z;     // West
  buffer[3].x = targetTile.x + 1;
  buffer[3].z = targetTile.z;     // East
  return 4;
}
```

***

## Agility XP Tracking

The movement system tracks tiles traveled for Agility skill XP:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From tile-movement.ts (server-side)
private tilesTraveledForXP: Map<string, number> = new Map();

// Constants
const AGILITY_TILES_PER_XP_GRANT = 100;  // Tiles needed before XP is granted
const AGILITY_XP_PER_GRANT = 50;         // XP granted per threshold

// Track tiles moved during tick processing
const tilesMoved = Math.abs(state.currentTile.x - prevTile.x) + 
                   Math.abs(state.currentTile.z - prevTile.z);

if (tilesMoved > 0) {
  const currentTiles = (this.tilesTraveledForXP.get(playerId) || 0) + tilesMoved;
  
  if (currentTiles >= AGILITY_TILES_PER_XP_GRANT) {
    // Grant XP and preserve overflow
    const grantsEarned = Math.floor(currentTiles / AGILITY_TILES_PER_XP_GRANT);
    const xpToGrant = grantsEarned * AGILITY_XP_PER_GRANT;
    this.tilesTraveledForXP.set(playerId, currentTiles % AGILITY_TILES_PER_XP_GRANT);
    
    // Emit XP gain event
    this.world.emit(EventType.SKILLS_XP_GAINED, {
      playerId,
      skill: 'agility',
      amount: xpToGrant,
    });
  } else {
    // Accumulate tiles silently
    this.tilesTraveledForXP.set(playerId, currentTiles);
  }
}
```

**XP Batching Design**:

* Prevents visual spam (XP drop every \~15 seconds running, \~30 seconds walking)
* Preserves partial progress between batches
* Death resets tile counter (small penalty)
* Logout/disconnect clears counter (max \~50 XP lost)

<Info>
  Agility XP is granted automatically as players move. Both walking and running count toward XP at the same rate (1 XP per 2 tiles).
</Info>

***

## Stamina System

Stamina is a client-side mechanic that affects running ability. It's influenced by both Agility level and inventory weight.

### Base Stamina Rates

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From PlayerLocal.ts
private readonly staminaDrainPerSecond: number = 2;              // While running
private readonly staminaRegenWhileWalkingPerSecond: number = 2;  // While walking
private readonly staminaRegenPerSecond: number = 4;              // While idle
```

### Weight-Based Drain

Inventory weight increases stamina drain while running:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Weight modifier: +0.5% drain per kg carried
private readonly weightDrainModifier: number = 0.005;

// Calculate drain rate with weight
const weightMultiplier = 1 + this.totalWeight * this.weightDrainModifier;
const drainRate = this.staminaDrainPerSecond * weightMultiplier;
```

**Weight Impact**:

| Weight (kg) | Drain Multiplier | Drain/Second | Stamina Duration |
| ----------- | ---------------- | ------------ | ---------------- |
| 0           | 1.0x             | 2.0          | 50 seconds       |
| 20          | 1.1x             | 2.2          | 45 seconds       |
| 50          | 1.25x            | 2.5          | 40 seconds       |
| 100         | 1.5x             | 3.0          | 33 seconds       |

### Agility-Based Regeneration

Agility level increases stamina regeneration:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Agility modifier: +1% regen per level
private readonly agilityRegenModifier: number = 0.01;

// Calculate regen rate with agility
const agilityMultiplier = 1 + this.skills.agility.level * this.agilityRegenModifier;
const regenRate = baseRegenRate * agilityMultiplier;
```

**Agility Impact**:

| Agility Level | Regen Multiplier | Idle Regen/Sec | Walk Regen/Sec |
| ------------- | ---------------- | -------------- | -------------- |
| 1             | 1.01x            | 4.04           | 2.02           |
| 25            | 1.25x            | 5.00           | 2.50           |
| 50            | 1.50x            | 6.00           | 3.00           |
| 75            | 1.75x            | 7.00           | 3.50           |
| 99            | 1.99x            | 7.96           | 3.98           |

### Weight Synchronization

Player weight is calculated server-side and synced to the client:

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Server: InventorySystem emits weight changes
const totalWeight = this.getTotalWeight(playerId);
this.emitTypedEvent(EventType.PLAYER_WEIGHT_CHANGED, {
  playerId,
  weight: totalWeight,
});

// Client: PlayerLocal receives weight updates
onPlayerWeightUpdated = (data: { playerId: string; weight: number }) => {
  const localPlayer = this.world.getPlayer?.();
  if (localPlayer && data.playerId === localPlayer.id) {
    localPlayer.totalWeight = data.weight;
  }
};
```

<Warning>
  Weight is server-authoritative to prevent client-side manipulation. The Equipment Panel displays the server-synced weight value.
</Warning>

***

## Client Interpolation

The client smoothly interpolates entity positions between server ticks.

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From ClientNetwork.ts
interface InterpolationState {
  entityId: string;
  snapshots: EntitySnapshot[];      // Buffer of last 3 positions
  snapshotIndex: number;
  currentPosition: THREE.Vector3;   // Interpolated position
  currentRotation: THREE.Quaternion;
  lastUpdate: number;
}

// Interpolate between snapshots for 60 FPS visuals
function interpolateEntity(state: InterpolationState, alpha: number): void {
  const prev = state.snapshots[state.snapshotIndex];
  const next = state.snapshots[(state.snapshotIndex + 1) % 3];

  state.currentPosition.lerpVectors(prev.position, next.position, alpha);
  state.currentRotation.slerpQuaternions(prev.rotation, next.rotation, alpha);
}
```

***

## Terrain Flattening

Stations and structures can flatten terrain underneath for level building surfaces using the **Flat Zone System**.

### Flat Zone Interface

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// From packages/shared/src/types/world/terrain.ts
export interface FlatZone {
  id: string;              // Unique identifier (e.g., "station_furnace_spawn_1")
  centerX: number;         // Center X position in world coordinates (meters)
  centerZ: number;         // Center Z position in world coordinates (meters)
  width: number;           // Width in meters (X axis)
  depth: number;           // Depth in meters (Z axis)
  height: number;          // Target height for the flat area (meters)
  blendRadius: number;     // Blend radius for smooth transition (meters)
}
```

### TerrainSystem API

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// 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}`);
}
```

### How It Works

1. **Height Calculation Priority**: 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:**

```typescript theme={"theme":{"light":"github-light","dark":"css-variables"}}
// Smoothstep interpolation: t² × (3 - 2t)
const t = blend * blend * (3 - 2 * blend);
const finalHeight = flatHeight + (proceduralHeight - flatHeight) * t;
```

<Info>
  Flat zones are loaded from `world-areas.json` during terrain initialization. Station footprints are calculated from model bounds × scale.
</Info>

***

## Related Documentation

* [Skills System](/wiki/game-systems/skills) - Agility skill mechanics
* [Combat System](/wiki/game-systems/combat) - Uses tile range for attacks
* [NPC Behavior](/wiki/data/npcs) - NPC aggro and pathing
* [Entity System](/wiki/engine/ecs) - Entity management
* [Manifests](/concepts/manifests) - Station configuration with terrain flattening
