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
);
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)
Station and resource footprints are automatically calculated from 3D model bounds:
# Runs automatically during build (cached by Turbo)
bun run extract-bounds
Process:
- Scans
world/assets/models/**/*.glb files
- Parses glTF position accessor min/max values
- Calculates bounding boxes at scale 1.0
- 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
| World Size | Zones | Memory |
|---|
| 100×100 tiles | 13×13 = 169 | ~43 KB |
| 500×500 tiles | 63×63 = 3,969 | ~1 MB |
| 1000×1000 tiles | 125×125 = 15,625 | ~4 MB |
| Operation | Complexity | Allocations |
|---|
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