Skip to main content

Collision System

Hyperscape implements a unified collision system for OSRS-accurate tile blocking. The system handles static objects (trees, rocks, stations), entities (players, NPCs), and terrain (water, slopes) using efficient zone-based storage.

Architecture

CollisionMatrix

The core collision storage uses zone-based chunking for optimal memory and cache performance:
import { CollisionMatrix, CollisionFlag, CollisionMask } from '@hyperscape/shared';

const collision = new CollisionMatrix();

// Add blocking for a tree at tile (10, 15)
collision.addFlags(10, 15, CollisionFlag.BLOCKED);

// Check if tile is walkable
if (collision.isWalkable(10, 15)) {
  // Safe to move
}

// Check for specific flags
if (collision.hasFlags(10, 15, CollisionMask.BLOCKS_WALK)) {
  // Blocked by static object, water, or slope
}
Zone-Based Storage:
  • World divided into 8×8 tile zones
  • Each zone = Int32Array[64] = 256 bytes
  • Lazy allocation (zones created on first write)
  • 1000×1000 tile world = ~4MB memory
Performance:
  • O(1) tile lookups via array indexing
  • Zero allocations in hot paths
  • Bitwise operations for flag queries
  • Delta-based entity moves (only update changed tiles)

Collision Flags

Tiles use bitmask flags for efficient collision queries:

Individual Flags

export const CollisionFlag = {
  // Static objects
  BLOCKED: 0x00200000,        // Trees, rocks, stations
  WATER: 0x00800000,          // Water tiles
  STEEP_SLOPE: 0x01000000,    // Impassable terrain
  DECORATION: 0x00040000,     // Visual only (doesn't block)
  BLOCK_LOS: 0x00400000,      // Blocks line of sight (ranged)
  
  // Entity occupancy
  OCCUPIED_PLAYER: 0x00000100,
  OCCUPIED_NPC: 0x00000200,
  
  // Directional walls (for dungeons/buildings)
  WALL_NORTH: 0x00000002,
  WALL_EAST: 0x00000008,
  WALL_SOUTH: 0x00000020,
  WALL_WEST: 0x00000080,
  WALL_NORTH_WEST: 0x00000001,
  WALL_NORTH_EAST: 0x00000004,
  WALL_SOUTH_EAST: 0x00000010,
  WALL_SOUTH_WEST: 0x00000040,
} as const;

Combined Masks

export const CollisionMask = {
  // Static blocking only (excludes entities)
  BLOCKS_WALK: BLOCKED | WATER | STEEP_SLOPE,
  
  // Any entity occupying tile
  OCCUPIED: OCCUPIED_PLAYER | OCCUPIED_NPC,
  
  // Full blocking including entities
  BLOCKS_MOVEMENT: BLOCKS_WALK | OCCUPIED,
  
  // Ranged combat blocking
  BLOCKS_RANGED: BLOCK_LOS | BLOCKED,
  
  // All wall flags
  WALLS: WALL_NORTH | WALL_EAST | WALL_SOUTH | WALL_WEST |
         WALL_NORTH_WEST | WALL_NORTH_EAST | 
         WALL_SOUTH_EAST | WALL_SOUTH_WEST,
} as const;

Usage Examples

Basic Collision Checks

// Check if tile is walkable (no blocking flags)
if (world.collision.isWalkable(tileX, tileZ)) {
  player.moveTo(tileX, tileZ);
}

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

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

Adding/Removing Collision

// Add blocking for a tree
const treeTile = worldToTile(tree.position.x, tree.position.z);
world.collision.addFlags(treeTile.x, treeTile.z, CollisionFlag.BLOCKED);

// Remove blocking when tree is cut down
world.collision.removeFlags(treeTile.x, treeTile.z, CollisionFlag.BLOCKED);

// Set multiple flags at once
world.collision.setFlags(
  tileX, 
  tileZ, 
  CollisionFlag.BLOCKED | CollisionFlag.BLOCK_LOS
);

Multi-Tile Footprints

Stations and large resources can occupy multiple tiles:
// 2×2 furnace centered at (10, 10)
const centerTile = worldToTile(10.5, 10.5); // (10, 10)
const footprint = { width: 2, depth: 2 };

// Calculate offset to center footprint
const offsetX = Math.floor(footprint.width / 2);  // 1
const offsetZ = Math.floor(footprint.depth / 2);  // 1

// Register all occupied tiles
for (let dx = 0; dx < footprint.width; dx++) {
  for (let dz = 0; dz < footprint.depth; dz++) {
    const tile = {
      x: centerTile.x + dx - offsetX,
      z: centerTile.z + dz - offsetZ,
    };
    world.collision.addFlags(tile.x, tile.z, CollisionFlag.BLOCKED);
  }
}
// Occupies: (9,9), (10,9), (9,10), (10,10)
Footprints are centered on the entity position, not corner-based. This ensures consistent interaction from all sides.

Interaction Range Checks

Players can interact with multi-tile objects from any adjacent tile:
import { tilesWithinRangeOfFootprint } from '@hyperscape/shared';

// Check if player is in range of a 2×2 furnace
const inRange = tilesWithinRangeOfFootprint(
  playerTile,
  furnaceCenterTile,
  2, // width
  2, // depth
  1  // range (adjacent tiles)
);

if (inRange) {
  // Player can interact with furnace
}

Entity Occupancy

The EntityOccupancyMap tracks entity positions and delegates to CollisionMatrix:
// Occupy tiles (adds OCCUPIED_PLAYER or OCCUPIED_NPC flag)
world.entityOccupancy.occupy(
  entityId,
  tiles,
  tileCount,
  "player",
  false // ignoresCollision
);

// Move entity (atomic operation with delta updates)
world.entityOccupancy.move(entityId, newTiles, newTileCount);

// Vacate tiles (removes occupancy flags)
world.entityOccupancy.vacate(entityId);

// Check if tile is occupied
if (world.entityOccupancy.isOccupied(tile)) {
  // Another entity is here
}
Boss Collision: Bosses can ignore entity collision while still being tracked:
// Boss is tracked but doesn't block other entities
world.entityOccupancy.occupy(
  bossId,
  tiles,
  tileCount,
  "npc",
  true // ignoresCollision = true
);

Pathfinding Integration

The pathfinding system checks collision when finding paths:
// From BFSPathfinder.ts
const pathfinder = new BFSPathfinder({
  getEntityId: () => entityId,
  getEntityOccupancy: () => world.entityOccupancy,
  isWalkable: (tile) => {
    // Check CollisionMatrix for static objects
    if (world.collision.hasFlags(tile.x, tile.z, CollisionMask.BLOCKS_WALK)) {
      return false;
    }
    
    // Check terrain walkability
    const terrain = world.getSystem('terrain');
    return terrain?.isTileWalkable(tile) ?? true;
  },
});

const path = pathfinder.findPath(startTile, goalTile);
Pathfinding uses BLOCKS_WALK mask (excludes OCCUPIED flags) so entities can path through other entities. Collision is checked at movement execution time.

Network Synchronization

Collision data is synchronized from server to client using zone serialization:
// Server: Serialize zones near player
const zones = world.collision.getZonesInRadius(
  playerTile.x,
  playerTile.z,
  64 // radius in tiles
);

const packet = {
  zones: zones.map(z => ({
    zoneX: z.zoneX,
    zoneZ: z.zoneZ,
    data: world.collision.serializeZone(z.zoneX, z.zoneZ),
  })),
};

// Client: Deserialize and apply
for (const zone of packet.zones) {
  world.collision.deserializeZone(zone.zoneX, zone.zoneZ, zone.data);
}
Serialization Format:
  • Zone data = Int32Array[64] = 256 bytes
  • Base64 encoded for network transport (~344 chars)
  • Only allocated zones are sent (sparse data)

Automatic Footprint Detection

Station and resource footprints are automatically calculated from 3D model bounds:

Build-Time Extraction

# Runs automatically during build (cached by Turbo)
bun run extract-bounds
Process:
  1. Scans world/assets/models/**/*.glb files
  2. Parses glTF position accessor min/max values
  3. Calculates bounding boxes at scale 1.0
  4. Writes to world/assets/manifests/model-bounds.json
Example Output:
{
  "generatedAt": "2026-01-15T11:25:00.000Z",
  "tileSize": 1.0,
  "models": [
    {
      "id": "furnace",
      "assetPath": "asset://models/furnace/furnace.glb",
      "bounds": {
        "min": { "x": -0.755, "y": 0.0, "z": -0.725 },
        "max": { "x": 0.755, "y": 2.1, "z": 0.725 }
      },
      "dimensions": { "x": 1.51, "y": 2.1, "z": 1.45 },
      "footprint": { "width": 2, "depth": 1 }
    }
  ]
}

Runtime Calculation

StationDataProvider combines model bounds with modelScale from stations.json:
// Furnace model raw dimensions: 1.51 × 1.45 meters
// modelScale from stations.json: 1.5
// Scaled dimensions: 2.27 × 2.18 meters
// Footprint: Math.round(2.27) × Math.round(2.18) = 2×2 tiles

const footprint = stationDataProvider.getFootprint("furnace");
// Returns: { width: 2, depth: 2 }
Benefits:
  • No manual footprint configuration
  • Footprints stay in sync with 3D models
  • Turbo caching avoids rebuilding when models unchanged
  • Override available via footprint field in stations.json

OSRS Accuracy

Depleted Resources

Resources remain solid even when depleted (OSRS behavior):
// Tree is cut down
resource.deplete();

// Collision remains (stump still blocks movement)
// Tiles stay BLOCKED until resource respawns or is destroyed

Safespotting

Players can use trees and rocks as obstacles to avoid melee combat:
// Player at (9, 10), tree at (10, 10), mob at (11, 10)
// Mob cannot path to player (tree blocks)
// Player can use ranged attacks (line of sight check separate)

Multi-Tile Interaction

Players can interact with multi-tile objects from any adjacent tile:
// 2×2 bank booth at (10, 10) occupies (9,9), (10,9), (9,10), (10,10)
// Player at (8, 9) is adjacent to (9, 9) → can interact
// Player at (11, 10) is adjacent to (10, 10) → can interact
// Player at (11, 11) is diagonal from (10, 10) → can interact

Performance Characteristics

Memory Footprint

World SizeZonesMemory
100×100 tiles13×13 = 169~43 KB
500×500 tiles63×63 = 3,969~1 MB
1000×1000 tiles125×125 = 15,625~4 MB

Hot Path Performance

OperationComplexityAllocations
getFlags()O(1)0
isWalkable()O(1)0
isBlocked()O(1)0
addFlags()O(1)0 (zone exists)
move() (entity)O(tiles)0 (delta update)

API Reference

CollisionMatrix

class CollisionMatrix implements ICollisionMatrix {
  // Flag operations
  getFlags(tileX: number, tileZ: number): number;
  setFlags(tileX: number, tileZ: number, flags: number): void;
  addFlags(tileX: number, tileZ: number, flags: number): void;
  removeFlags(tileX: number, tileZ: number, flags: number): void;
  hasFlags(tileX: number, tileZ: number, flags: number): boolean;
  
  // Movement checks
  isWalkable(tileX: number, tileZ: number): boolean;
  isBlocked(fromX: number, fromZ: number, toX: number, toZ: number): boolean;
  
  // Network sync
  serializeZone(zoneX: number, zoneZ: number): string | null;
  deserializeZone(zoneX: number, zoneZ: number, base64Data: string): boolean;
  getZonesInRadius(centerX: number, centerZ: number, radius: number): ZoneData[];
  
  // Utilities
  clear(): void;
  getZoneCount(): number;
}

EntityOccupancyMap

class EntityOccupancyMap implements IEntityOccupancy {
  // Entity tracking
  occupy(entityId: EntityID, tiles: TileCoord[], count: number, 
         type: 'player' | 'npc', ignoresCollision: boolean): void;
  vacate(entityId: EntityID): void;
  move(entityId: EntityID, newTiles: TileCoord[], count: number): void;
  
  // Queries
  isOccupied(tile: TileCoord): boolean;
  isBlocked(tile: TileCoord, excludeEntityId?: EntityID): boolean;
  getEntityAt(tile: TileCoord): EntityID | null;
  
  // Integration
  setCollisionMatrix(matrix: ICollisionMatrix): void;
}

Utility Functions

// Multi-tile interaction range check
function tilesWithinRangeOfFootprint(
  playerTile: TileCoord,
  centerTile: TileCoord,
  footprintWidth: number,
  footprintDepth: number,
  rangeTiles: number,
): boolean;

// Directional wall helpers
function getWallFlagForDirection(dx: number, dz: number): number;
function getOppositeWallFlag(flag: number): number;

Implementation Details

Zone Coordinate Calculation

// Zone key from tile coordinates
private getZoneKey(tileX: number, tileZ: number): string {
  const zoneX = Math.floor(tileX / ZONE_SIZE);
  const zoneZ = Math.floor(tileZ / ZONE_SIZE);
  return `${zoneX},${zoneZ}`;
}

// Tile index within zone (0-63)
private getTileIndex(tileX: number, tileZ: number): number {
  const localX = ((tileX % ZONE_SIZE) + ZONE_SIZE) % ZONE_SIZE;
  const localZ = ((tileZ % ZONE_SIZE) + ZONE_SIZE) % ZONE_SIZE;
  return localX + localZ * ZONE_SIZE;
}
Negative coordinates are handled correctly using Math.floor for zone calculation and corrected modulo for tile index.

Atomic Entity Moves

Entity moves update collision atomically with delta optimization:
// Only tiles that changed are updated
// 3×3 boss moving 1 tile: 6 unchanged, 3 removed, 3 added
world.entityOccupancy.move(bossId, newTiles, newTileCount);

// Internally:
// 1. Remove flags from old tiles not in new position
// 2. Add flags to new tiles not in old position
// 3. Update tracking to new tile set

Testing

The collision system includes comprehensive unit tests:
// From packages/shared/src/systems/shared/movement/__tests__/
describe('CollisionMatrix', () => {
  it('handles negative coordinates', () => {
    matrix.setFlags(-5, -10, CollisionFlag.BLOCKED);
    expect(matrix.getFlags(-5, -10)).toBe(CollisionFlag.BLOCKED);
  });
  
  it('blocks diagonal when adjacent tile is blocked', () => {
    matrix.setFlags(6, 5, CollisionFlag.BLOCKED);
    expect(matrix.isBlocked(5, 5, 6, 6)).toBe(true);
  });
});
Test Coverage:
  • Zone allocation and storage
  • Flag operations (add, remove, query)
  • Negative coordinate handling
  • Directional wall blocking
  • Diagonal movement clipping
  • Network serialization
  • Multi-tile footprints

Tree Collision Proxy (March 2026)

Improvement (PR #1100): Tree collision detection now uses actual LOD2 model geometry instead of oversized cylinders for pixel-accurate click detection.

Problem

The old system used invisible cylinder hitboxes with 0.4 radius factor, which were much larger than the visible tree silhouette. This caused ground clicks near trees to be intercepted by the collision proxy instead of registering as ground clicks.

Solution

Replace cylinder with actual LOD2 mesh geometry so clicks only register on the visible tree silhouette:
// From TreeGLBVisualStrategy.ts
function createCollisionProxy(ctx: ResourceVisualContext, scale: number, batched: boolean): void {
  // Get LOD2 geometry for pixel-accurate collision
  const proxyData = batched
    ? getBatchedProxyGeometry(ctx.id)
    : getInstancedProxyGeometry(ctx.id);
    
  const cachedGeometry = proxyData
    ? getOrCreateProxyGeometry(proxyData.geometries, scale)
    : null;
    
  if (cachedGeometry && proxyData) {
    // Use actual model geometry
    geometry = cachedGeometry;
    yPos = proxyData.yOffset * scale;
  } else {
    // Fallback: tighter cylinder (0.25 radius factor, down from 0.4)
    const radius = Math.max(fullRadius * 0.25, 0.3);
    geometry = new THREE.CylinderGeometry(radius, radius, height, 6);
    yPos = height / 2;
  }
}

Geometry Merging

Multi-part tree models (bark + leaves) are merged into a single collision proxy:
/**
 * Merge multiple BufferGeometry parts into one for the collision proxy.
 * Only copies position + index — normals/UVs are unnecessary for raycasting.
 */
function mergeGeometries(parts: THREE.BufferGeometry[]): THREE.BufferGeometry | null {
  // Filter out any parts missing position data
  const valid = parts.filter((g) => g.getAttribute("position"));
  if (valid.length === 0) return null;
  
  // Single-part: return shared geometry directly (caller must clone)
  if (valid.length === 1) return valid[0];
  
  // Multi-part: merge positions and indices
  // ... (see TreeGLBVisualStrategy.ts for full implementation)
}

Proxy Geometry Cache

Merged geometries are cached per (sourceGeometries, scale) to avoid redundant work:
// Cache key: (geometry array reference, rounded scale)
const key = Math.round(scale * 1000) / 1000;  // Round to 3 decimals
let scaleMap = _proxyGeometryCache.get(sourceGeometries);

if (scaleMap?.has(key)) {
  return scaleMap.get(key);  // Return cached geometry
}

// Create, cache, and return new merged+scaled geometry
const merged = mergeGeometries(sourceGeometries);
const scaled = merged.clone().scale(scale, scale, scale);
scaled.computeBoundingBox();
scaled.computeBoundingSphere();
scaleMap.set(key, scaled);
Cache Features:
  • Float Key Safety: Scale rounded to 3 decimal places prevents floating-point cache misses
  • Defensive Bounds: Pre-computes boundingBox and boundingSphere to prevent lazy mutation by Three.js raycaster
  • Memory Management: Cache cleared during world teardown via clearProxyGeometryCache()
Proxy geometries are shared across all trees with the same model+scale. The geometry must not be mutated. The proxy mesh is invisible and used only for raycasting, so Three.js internals won’t modify it in normal operation.

API Reference

/**
 * Get proxy geometry for collision detection.
 * Returns the lowest-available LOD geometries (prefers LOD2 → LOD1 → LOD0).
 * 
 * IMPORTANT: Returned geometries are shared by the instancer pool.
 * Callers MUST clone before mutating.
 */
export function getProxyGeometry(
  entityId: string
): { geometries: THREE.BufferGeometry[]; yOffset: number } | null;

/**
 * Clear the proxy geometry cache and dispose all cached geometries.
 * Must be called during world teardown to prevent GPU buffer leaks.
 */
export function clearProxyGeometryCache(): void;

Benefits

  • Accurate Click Detection: Clicks only register on visible tree silhouette
  • No Ground Click Interception: Ground clicks near trees work correctly
  • Memory Efficient: Cached geometry shared across all trees with same model+scale
  • Graceful Fallback: Uses tighter cylinder (0.25 radius) if LOD unavailable

Tile Movement

Tile-based movement, pathfinding, and distance calculations.

Manifests

Model bounds extraction and station configuration.

Combat System

Combat range checks and line of sight.

NPC AI

Mob pathfinding and aggro behavior.