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.
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 walkableif (collision.isWalkable(10, 15)) { // Safe to move}// Check for specific flagsif (collision.hasFlags(10, 15, CollisionMask.BLOCKS_WALK)) { // Blocked by static object, water, or slope}
// 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");}
// Add blocking for a treeconst treeTile = worldToTile(tree.position.x, tree.position.z);world.collision.addFlags(treeTile.x, treeTile.z, CollisionFlag.BLOCKED);// Remove blocking when tree is cut downworld.collision.removeFlags(treeTile.x, treeTile.z, CollisionFlag.BLOCKED);// Set multiple flags at onceworld.collision.setFlags( tileX, tileZ, CollisionFlag.BLOCKED | CollisionFlag.BLOCK_LOS);
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 furnaceconst inRange = tilesWithinRangeOfFootprint( playerTile, furnaceCenterTile, 2, // width 2, // depth 1 // range (adjacent tiles));if (inRange) { // Player can interact with furnace}
Pathfinding uses BLOCKS_WALK mask (excludes OCCUPIED flags) so entities can path through other entities. Collision is checked at movement execution time.
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)
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
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 addedworld.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
Improvement (PR #1100): Tree collision detection now uses actual LOD2 model geometry instead of oversized cylinders for pixel-accurate click detection.
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.
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)}
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.
/** * 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;