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
}
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 Multiplier | Drain/Second | Stamina Duration |
|---|
| 0 | 1.0x | 2.0 | 50 seconds |
| 20 | 1.1x | 2.2 | 45 seconds |
| 50 | 1.25x | 2.5 | 40 seconds |
| 100 | 1.5x | 3.0 | 33 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 Level | Regen Multiplier | Idle Regen/Sec | Walk Regen/Sec |
|---|
| 1 | 1.01x | 4.04 | 2.02 |
| 25 | 1.25x | 5.00 | 2.50 |
| 50 | 1.50x | 6.00 | 3.00 |
| 75 | 1.75x | 7.00 | 3.50 |
| 99 | 1.99x | 7.96 | 3.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
- Height Calculation Priority: Flat zones checked before procedural terrain
- Core Flat Area: Inside the zone, terrain returns exact
height value
- Blend Area: Within
blendRadius of zone edge, smoothstep interpolation blends to procedural terrain
- Spatial Indexing: Terrain tiles (100m) used for O(1) lookup
- 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.