Skip to main content

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.
The tile system lives in packages/shared/src/systems/shared/movement/TileSystem.ts.

Core Constants

// 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
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.

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)
Agility XP is tracked server-side in TileMovementManager and granted via the SKILLS_XP_GAINED event. See Skills System for details on agility’s stamina regeneration bonus.

Tile Coordinates

Tiles use integer coordinates on the X-Z plane. Height (Y) comes from terrain.
// Tile coordinate (always integers)
export interface TileCoord {
  x: number; // Integer tile X
  z: number; // Integer tile Z
}

World ↔ Tile Conversion

// 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:
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)

// 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:
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:
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

// 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

// 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

OSRS Accuracy: Standard melee (range 1) requires cardinal adjacency only. You cannot attack diagonally without a halberd (range 2).
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

// 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.
// 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.
// 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:
// 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:
// 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:
// 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

// 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:
// 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
);
Footprints are centered on the entity position, not corner-based. A 2×2 station at (10,10) occupies tiles (9,9) through (10,10).

Entity Occupancy

The EntityOccupancyMap tracks which tiles are occupied by entities and delegates to CollisionMatrix for unified storage:
// 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;
}
Entity moves are atomic - old tiles are freed and new tiles occupied in a single operation. Delta optimization ensures only changed tiles are updated.

Zero-Allocation Helpers

For performance in hot paths, use pre-allocated buffers:
// 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:
// 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)
Agility XP is granted automatically as players move. Both walking and running count toward XP at the same rate (1 XP per 2 tiles).

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

// 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:
// 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 MultiplierDrain/SecondStamina Duration
01.0x2.050 seconds
201.1x2.245 seconds
501.25x2.540 seconds
1001.5x3.033 seconds

Agility-Based Regeneration

Agility level increases stamina regeneration:
// 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 LevelRegen MultiplierIdle Regen/SecWalk Regen/Sec
11.01x4.042.02
251.25x5.002.50
501.50x6.003.00
751.75x7.003.50
991.99x7.963.98

Weight Synchronization

Player weight is calculated server-side and synced to the client:
// 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;
  }
};
Weight is server-authoritative to prevent client-side manipulation. The Equipment Panel displays the server-synced weight value.

Client Interpolation

The client smoothly interpolates entity positions between server ticks.
// 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

// 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

// 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:
// Smoothstep interpolation: t² × (3 - 2t)
const t = blend * blend * (3 - 2 * blend);
const finalHeight = flatHeight + (proceduralHeight - flatHeight) * t;
Flat zones are loaded from world-areas.json during terrain initialization. Station footprints are calculated from model bounds × scale.