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.

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
];

Performance Improvements (February 2026)

Immediate Move Processing

Bypasses ActionQueue for instant response to player clicks (eliminates 0-600ms latency)

Pathfinding Rate Limit

Raised from 5/sec to 15/sec to match tile movement limiter

BFS Iterations

Increased from 2000 to 8000 (~44 tile radius vs ~22 tile)

Path Continuation

Seamless long-distance movement with automatic re-pathfinding when BFS limit reached
Skating Fix: Server-side pre-computation + client-side path appending eliminates stop-lurch at segment boundaries Multi-Click Feel: Optimistic target pivoting + pending move queue ensures last click always reaches server Per-Frame Allocation Elimination: Pre-allocated buffers and squared distance comparisons in hot paths

Path Continuation for Long-Distance Movement

When a click exceeds the BFS iteration limit (~44 tiles), the system automatically continues pathfinding. New fields added to TileMovementState:
// NEW fields added February 2026
requestedDestination: TileCoord | null;  // Original click target
lastPathPartial: boolean;                // True if last BFS hit iteration limit
nextSegmentPrecomputed: boolean;         // True if next segment already sent
How It Works:
1

Initial Click

Player clicks far destination (60 tiles away). BFS runs for 8000 iterations, finds partial path to tile 44. Sets requestedDestination = (60, 60) and lastPathPartial = true
2

Path Continuation

When player reaches end of partial path, system automatically re-pathfinds from tile 44 toward original destination (60, 60). Finds next segment (tiles 44-88 or until destination). Continues until destination reached or unreachable.
3

Seamless Movement

Client receives isContinuation: true flag and appends new path without resetting interpolator. No stop-lurch at segment boundaries.
Server-Side Pre-Computation: Sends next segment 1 tick early to eliminate RTT/2 idle gap at segment boundaries. Client-Side Path Appending: Appends new path segments without resetting interpolator for continuous movement.

Pathfinding

BFS Pathfinder (OSRS “Smartpathing”)

Breaking Change (PR #886): Player movement now uses BFS as the primary pathfinder instead of naive diagonal-first. This is a significant change that affects all player movement and combat positioning.
Hyperscape uses BFS (Breadth-First Search) as the primary pathfinding algorithm for all player movement, matching OSRS “smartpathing”:
// From BFSPathfinder.ts
export class BFSPathfinder {
  /**
   * Find optimal path from start to end using BFS.
   * BFS is the primary pathfinder for all player movement.
   */
  findPath(
    start: TileCoord,
    end: TileCoord,
    isWalkable: WalkabilityChecker,
  ): TileCoord[];

  /**
   * Multi-destination BFS: find shortest path to ANY destination tile.
   * Used for combat pathfinding — finds the closest reachable attack position.
   */
  findPathToAny(
    start: TileCoord,
    destinations: TileCoord[],
    isWalkable: WalkabilityChecker,
  ): TileCoord[];

  /**
   * Naive diagonal pathing — "dumb pathfinding" for NPC chase systems.
   * Moves diagonally toward target first, then cardinally.
   * NOT used for player movement (players use BFS).
   */
  findNaivePath(
    start: TileCoord,
    end: TileCoord,
    isWalkable: WalkabilityChecker,
  ): TileCoord[];
}
OSRS Accuracy: Player movement uses BFS (“smartpathing”) which finds optimal paths around obstacles. NPC chase movement uses naive diagonal pathing, which enables safespotting mechanics.

Multi-Destination BFS for Combat

Combat movement uses findPathToAny() to find the shortest path to any valid attack tile:
// Generate all valid attack tiles
const validTiles = attackType === AttackType.MELEE
  ? getValidMeleeTiles(targetTile, attackRange, isWalkable)
  : getValidRangedTiles(targetTile, attackRange, isWalkable, hasBlockingFlags);

// Find shortest path to ANY valid tile
const path = pathfinder.findPathToAny(
  currentTile,
  validTiles,
  isWalkable,
);
Benefits:
  • Naturally selects the closest reachable attack position
  • No need to pre-pick a “best” tile then pathfind to it
  • Terminates as soon as any valid tile is reached
  • Handles obstacles and blocked tiles automatically
Example:
Target at (10, 10)
Valid melee tiles: (9,10), (11,10), (10,9), (10,11)
Player at (5, 10)

BFS explores: (6,10) → (7,10) → (8,10) → (9,10) ✓
Stops immediately — found a valid attack tile
Path: [(6,10), (7,10), (8,10), (9,10)]

Line of Sight for Ranged/Magic

Ranged and magic attacks now require line of sight to prevent attacking through walls:
/**
 * OSRS-accurate Line of Sight check for ranged/magic combat.
 * Traces a line between two tiles using Bresenham's algorithm.
 */
export function hasLineOfSight(
  from: TileCoord,
  to: TileCoord,
  hasBlockingFlags: (x: number, z: number) => boolean,
): boolean;
Collision Mask:
// From CollisionFlags.ts
export const CollisionMask = {
  BLOCK_LOS: 0x02000000,      // Walls, solid objects
  BLOCKED: 0x00200000,        // Trees, rocks, stations
  BLOCKS_RANGED: BLOCK_LOS | BLOCKED,  // Combined mask for LoS checks
};
Usage in Combat:
// Check if ranged/magic attack has line of sight
const hasLoS = hasLineOfSight(
  attackerTile,
  targetTile,
  (x, z) => world.collision.hasFlags(x, z, CollisionMask.BLOCKS_RANGED),
);

if (!hasLoS) {
  // Attack blocked by wall/obstacle
  return;
}
Breaking Change: Ranged and magic attacks now require line of sight. You cannot attack through walls even if the target is within Chebyshev range.

Valid Combat Tile Generation

The tile system provides helpers to generate all valid attack positions:
/**
 * Generate all valid tiles for ranged/magic combat within Chebyshev range
 * that also have line of sight to the target.
 */
export function getValidRangedTiles(
  target: TileCoord,
  combatRange: number,
  isWalkable: (tile: TileCoord) => boolean,
  hasBlockingFlags: (x: number, z: number) => boolean,
): TileCoord[];

/**
 * Generate all valid tiles for melee combat.
 * Range 1 (standard): cardinal only (N/S/E/W).
 * Range 2+ (halberd): all Chebyshev tiles within range.
 */
export function getValidMeleeTiles(
  target: TileCoord,
  meleeRange: number,
  isWalkable: (tile: TileCoord) => boolean,
): TileCoord[];
Example: Ranged Combat Positioning
// Player wants to attack target at (10, 10) with bow (range 7)
const validTiles = getValidRangedTiles(
  { x: 10, z: 10 },
  7,
  (tile) => world.collision.isWalkable(tile),
  (x, z) => world.collision.hasFlags(x, z, CollisionMask.BLOCKS_RANGED),
);

// Returns all tiles 1-7 tiles away with clear line of sight
// Example result: [(10,3), (10,4), (11,5), (9,5), ...] (tiles with LoS)
// Excludes: tiles behind walls, blocked tiles, tiles at distance 0 or > 7
Example: Melee Combat Positioning
// Player wants to attack target at (10, 10) with sword (range 1)
const validTiles = getValidMeleeTiles(
  { x: 10, z: 10 },
  1,
  (tile) => world.collision.isWalkable(tile),
);

// Returns cardinal tiles only: [(9,10), (11,10), (10,9), (10,11)]
// Excludes diagonal tiles for range 1 (OSRS-accurate)

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;
}

Combat Movement Algorithm

Combat movement now uses multi-destination BFS instead of pre-selecting a single “best” tile:
// From tile-movement.ts
// OLD APPROACH (removed):
// 1. Pick "best" combat tile using heuristics
// 2. Pathfind to that specific tile
// 3. If blocked, fail

// NEW APPROACH (current):
// 1. Generate ALL valid attack tiles
// 2. Use findPathToAny() to find shortest path to ANY of them
// 3. Naturally selects closest reachable position

if (attackRange > 0) {
  // Generate ALL valid destination tiles
  let validTiles: TileCoord[];
  if (attackType === AttackType.RANGED || attackType === AttackType.MAGIC) {
    validTiles = getValidRangedTiles(
      targetTile,
      attackRange,
      (tile) => this.isTileWalkable(tile),
      (x, z) => this.world.collision.hasFlags(x, z, CollisionMask.BLOCKS_RANGED),
    );
  } else {
    validTiles = getValidMeleeTiles(
      targetTile,
      attackRange,
      (tile) => this.isTileWalkable(tile),
    );
  }

  if (validTiles.length === 0) {
    return; // No valid combat position found
  }

  // Multi-destination BFS: finds shortest path to ANY valid tile
  path = this.pathfinder.findPathToAny(
    state.currentTile,
    validTiles,
    (tile) => this.isTileWalkable(tile),
  );
}
Advantages:
  • Optimal pathing: Always finds the shortest path to a valid attack position
  • Obstacle handling: Automatically routes around walls and blocked tiles
  • Natural selection: Closest reachable tile is chosen without heuristics
  • Line of sight: Ranged/magic tiles pre-filtered for LoS before pathfinding

Naive Diagonal Pathing (NPCs Only)

NPC chase movement uses naive diagonal pathing to enable safespotting:
// From ChasePathfinding.ts
// NPCs use "dumb" diagonal-first pathing (OSRS-accurate for safespotting)
const path = pathfinder.findNaivePath(mobTile, targetTile, isWalkable);

// Naive algorithm:
// 1. Move diagonally toward target (both X and Z changing)
// 2. Then move cardinally (only X or Z) for remaining distance
// 3. If blocked, do nothing (enables safespotting)
Safespotting: The naive diagonal pathing used by NPCs allows players to position themselves where mobs cannot reach them, matching OSRS mechanics. Players use BFS which finds optimal paths, while mobs use naive pathing which can be exploited.

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;
}

Context Menu Integration

Walk Here

Players can right-click terrain to open a context menu with “Walk here”:
// From InteractionRouter.ts - terrain context menu
getContextMenuActions(target: RaycastTarget): ContextMenuAction[] {
  if (target.type === "terrain") {
    return [{
      id: "walk-here",
      label: "Walk here",
      enabled: true,
      priority: 1,
      handler: () => {
        // Send movement request to server
        world.network?.send("moveToPosition", {
          x: target.hitPoint.x,
          y: target.hitPoint.y,
          z: target.hitPoint.z,
        });
      },
    }];
  }
}
Context Menu Types: The system distinguishes between entity types and special cases:
// From interaction/types.ts
export type ContextMenuTargetType =
  | InteractableEntityType  // npc, mob, player, item, etc.
  | "terrain";              // Ground/floor (for "Walk here")

// InteractableEntityType includes all entity types
// ContextMenuTargetType adds terrain for right-click ground
The “Walk here” option only appears when right-clicking terrain. Entity interactions show entity-specific actions instead.

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