Overview
The @hyperscape/shared package is the core Hyperscape engine, providing:
- Entity Component System (ECS)
- Three.js WebGPU rendering (WebGPU required - no WebGL fallback)
- PhysX physics simulation
- Real-time networking
- React UI components
- Game data manifests
WebGPU Required: All rendering uses TSL (Three Shading Language) which only works with WebGPU. There is no WebGL fallback. Requires Chrome 113+, Edge 113+, or Safari 18+ (macOS 15+).
Package Location
packages/shared/
├── src/
│ ├── components/ # ECS components
│ ├── constants/ # Game constants
│ ├── core/ # Core engine classes
│ ├── data/ # Game manifests (npcs, items, etc.)
│ ├── entities/ # Entity definitions
│ │ ├── player/ # PlayerEntity, PlayerLocal, PlayerRemote
│ │ ├── npc/ # MobEntity, NPCEntity
│ │ ├── world/ # World entities
│ │ └── managers/ # Entity managers
│ ├── extras/ # Additional utilities
│ ├── libs/ # External integrations
│ ├── nodes/ # Scene graph nodes
│ ├── physics/ # PhysX wrapper
│ ├── platform/ # Platform detection
│ ├── runtime/ # Game loop
│ ├── systems/ # ECS systems
│ │ ├── client/ # Client-only systems
│ │ ├── server/ # Server-only systems
│ │ └── shared/ # Shared systems (combat, economy, etc.)
│ ├── types/ # TypeScript types
│ └── utils/ # Helper functions
├── index.ts # Server exports
└── index.client.ts # Client exports
Terrain Generation System
The shared package includes a sophisticated procedural terrain generation system with multi-threaded performance and cached height lookups for optimal performance.
Terrain Height Cache System
The terrain system uses a tile-based cache for fast height queries without re-evaluating noise functions. Critical bugs in the cache indexing were fixed in commit 21e0860 (Feb 25, 2026).
Cache Structure:
// From TerrainSystem.ts
private heightCache = new Map<string, Float32Array>(); // tileKey → height grid
private colorCache = new Map<string, Uint8Array>(); // tileKey → color grid
// Tile key format: "tileKey_x_z" (e.g., "tileKey_0_0", "tileKey_-1_2")
Critical Bug Fixes (commit 21e0860):
-
Tile Index Calculation Error:
- Issue:
getHeightAtCached used Math.floor(worldX/TILE_SIZE) which doesn’t account for PlaneGeometry’s centered coordinates
- Impact: Terrain tiles were indexed incorrectly, causing ~50m vertical offset in height queries
- Fix: Added
worldToTerrainTileIndex() canonical helper
- Formula:
export function worldToTerrainTileIndex(worldCoord: number): number {
return Math.floor((worldCoord + HALF_TILE_SIZE) / TILE_SIZE);
}
- Result: Terrain height queries now return correct values for all world positions
-
Grid Index Formula Error:
- Issue: Grid index formula omitted the
halfSize offset from PlaneGeometry’s [-50,+50] range
- Impact: Local coordinates within tiles were mapped incorrectly to height data arrays
- Fix: Added
localToGridIndex() canonical helper
- Formula:
export function localToGridIndex(localCoord: number, halfSize: number): number {
return Math.floor(localCoord + halfSize);
}
- Result: Height data lookups within tiles now use correct array indices
-
Color Cache Key Typo:
- Issue:
getTerrainColorAt() used comma separator (tileKey_x,z) instead of underscore (tileKey_x_z)
- Impact: Color cache lookups always failed, forcing expensive recomputation
- Fix: Corrected key format to match height cache convention
- Result: Color cache now works correctly, improving terrain rendering performance
Usage Example:
// Get height at world position (uses cache if available)
const height = terrainSystem.getHeightAt(worldX, worldZ);
// Get terrain color at world position (uses cache if available)
const color = terrainSystem.getTerrainColorAt(worldX, worldZ);
// Both methods now use canonical helpers internally:
const tileX = worldToTerrainTileIndex(worldX);
const tileZ = worldToTerrainTileIndex(worldZ);
const tileKey = `tileKey_${tileX}_${tileZ}`;
const heightGrid = this.heightCache.get(tileKey);
if (heightGrid) {
const localX = worldX - tileX * TILE_SIZE;
const localZ = worldZ - tileZ * TILE_SIZE;
const gridX = localToGridIndex(localX, HALF_TILE_SIZE);
const gridZ = localToGridIndex(localZ, HALF_TILE_SIZE);
const index = gridZ * resolution + gridX;
return heightGrid[index];
}
Impact of Fixes:
- Eliminates 50m vertical offset in player spawning and entity placement
- Fixes vegetation placement using incorrect terrain heights
- Prevents height query inconsistencies between cached and real-time lookups
- Improves terrain color rendering performance via working color cache
TerrainHeightParams.ts
Single source of truth for all terrain generation parameters. Both TerrainSystem (main thread) and TerrainWorker (web worker) consume these values to ensure identical terrain generation:
// Noise layer definitions
export const CONTINENT_LAYER: NoiseLayerDef = {
scale: 0.0008,
octaves: 5,
persistence: 0.7,
lacunarity: 2.0,
weight: 0.35,
};
export const HILL_LAYER: NoiseLayerDef = {
scale: 0.02,
octaves: 4,
persistence: 0.6,
lacunarity: 2.2,
weight: 0.25,
};
// Island configuration
export const ISLAND_RADIUS = 350;
export const ISLAND_FALLOFF = 100;
export const BASE_ELEVATION = 0.42;
// Worker code generation
export function buildGetBaseHeightAtJS(): string {
// Injects constants into worker string
}
Key Features:
- Prevents parameter drift between main thread and workers
- TypeScript constants injected into worker code at runtime
- Changing a constant automatically updates both threads
- Includes noise layers, island mask, pond, coastline, and mountain boost
Web Worker Terrain Generation
Terrain generation offloaded to web workers for parallel processing:
- Worker Height Computation: Full height calculation including shoreline adjustment
- Worker Normal Computation: Normals computed via (resolution+2)² overflow grid
- Performance: 63x reduction in main-thread noise evaluations
- Conditional Fallback: Main-thread recomputation only for tiles with flat zones (buildings/stations)
- Parallel Processing: Utilizes multiple CPU cores while keeping main thread free
GPU-Instanced Rendering Systems
GLBTreeInstancer (commit 0871acb)
InstancedMesh-based tree rendering system that replaces per-tree scene.clone(true) with shared InstancedMesh pools per LOD level.
Performance Impact:
- Eliminated per-tree geometry cloning (saves ~2-5ms per tree spawn)
- Reduced draw calls from N trees to 3 (one per LOD level)
- FPS improvement: ~15-20% in dense forest areas
- Memory savings: ~80% reduction in geometry buffer allocations
Implementation:
// From packages/shared/src/systems/shared/world/GLBTreeInstancer.ts
export class GLBTreeInstancer {
private pools: Map<string, LODPool> = new Map();
allocate(modelId: string, position: Vector3, rotation: number): InstanceHandle | null {
// Allocates instance slot from pre-sized pool (max 1000 per LOD)
}
free(handle: InstanceHandle): void {
// Returns instance slot to pool for reuse
}
setDepleted(handle: InstanceHandle, isDepleted: boolean): void {
// Handles stump state transitions
}
}
Integration:
- Initialized in
createClientWorld.ts when stage scene is ready
- ResourceEntity routes GLB trees through instancer instead of scene.clone()
- Handles depleted/stump/destroy lifecycle via no-op-safe calls
ResourceEntity Visual Strategy Pattern (commit bc60264)
Refactored ResourceEntity (~1700 lines removed) into delegated visual strategies using the Strategy Pattern.
Visual Strategies:
-
TreeGLBVisualStrategy: GLB tree models with LOD support
- Uses GLBTreeInstancer for instanced rendering
- Handles LOD transitions based on camera distance
- Manages stump state transitions
-
TreeProcgenVisualStrategy: Procedurally generated trees
- Uses ProcgenTreeInstancer for procedural geometry
- Supports runtime tree generation with L-system parameters
- Handles leaf/branch color variations
-
StandardModelVisualStrategy: Generic 3D models (rocks, ores)
- Loads GLB models via ModelCache
- Handles scale and rotation from manifest
- Supports depleted model swapping
-
FishingSpotVisualStrategy: Fishing spot particles
- Registers with ParticleManager for GPU-instanced water effects
- Handles ripple/splash/bubble animations
- Manages particle lifecycle on depletion
-
PlaceholderVisualStrategy: Fallback for missing models
- Uses PlaceholderInstancer for instanced colored cubes
- Color-coded by resource type (green=tree, brown=ore, blue=fishing)
- Automatically used when modelPath is null or “null” string
Factory Pattern:
// From packages/shared/src/entities/world/visuals/createVisualStrategy.ts
export function createVisualStrategy(
config: ResourceConfig,
entity: ResourceEntity
): ResourceVisualStrategy {
// Selects strategy based on resource type, model path, and manifest config
}
Benefits:
- Single Responsibility: Each strategy handles one rendering approach
- Open/Closed: Add new strategies without modifying ResourceEntity
- Testability: Strategies can be tested in isolation
- Maintainability: ~1700 lines of conditional logic replaced with clean delegation
PlaceholderInstancer:
- Manages InstancedMesh pools for placeholder resources (trees/ores with missing models)
- Color-coded: green (trees), brown (ores), blue (fishing spots)
- Max 1000 instances per resource type
- Initialized in
createClientWorld.ts alongside GLBTreeInstancer
Bug Fixes:
- Fixed fishing spot particles persisting after depletion (guard re-registration when depleted, zero ripple phase offset on unregister)
- Fixed placeholder trees not rendering due to “null” string in modelPath (sanitize in ResourceSystem + createVisualStrategy factory)
Particle System
The shared package includes a centralized GPU-instanced particle system for high-performance visual effects. This system was introduced in PR #877 and provides a 97% reduction in draw calls with an 80% FPS improvement.
ParticleManager Architecture
Unified particle routing system that dispatches particle events to specialized sub-managers:
// From packages/shared/src/entities/managers/particleManager/
export { ParticleManager } from './ParticleManager';
export { WaterParticleManager } from './WaterParticleManager';
Core Components:
- ParticleManager: Central router with ownership tracking and event dispatching
- WaterParticleManager: GPU-instanced fishing spot effects (splash, bubble, shimmer, ripple)
Performance Benefits:
- Reduces ~150 draw calls to 4 per fishing spot (97% reduction)
- FPS improvement: 65-70 → 120 on reference hardware (80% increase)
- ~450 lines of CPU animation code eliminated
- Zero CPU overhead for particle updates (all on GPU via TSL shaders)
- GPU-driven particle animations (parabolic arcs, wobble, twinkle, ring expansion)
WaterParticleManager Implementation
GPU-instanced water particle effects for fishing spots using InstancedMesh and TSL shaders:
Technical Details:
- 4 InstancedMeshes: splash, bubble, shimmer, ripple layers
- TSL NodeMaterials: GPU-computed animations using Three.js Shading Language
- InstancedBufferAttributes: Per-particle data storage
spotPos (vec3): fishing spot world center
ageLifetime (vec2): current age (x), total lifetime (y)
angleRadius (vec2): polar angle (x), radial distance (y)
dynamics (vec4): peakHeight (x), size (y), speed (z), direction (w)
- Vertex Buffer Budget: 7 of 8 max attributes per particle layer
- position(1) + uv(1) + instanceMatrix(1) + spotPos(1) + ageLifetime(1) + angleRadius(1) + dynamics(1)
- Ripple Layer: 5 of 8 max attributes
- position(1) + uv(1) + instanceMatrix(1) + spotPos(1) + rippleParams(1)
- Pool Sizes: MAX_SPLASH=96, MAX_BUBBLE=72, MAX_SHIMMER=72, MAX_RIPPLE=24
Particle Animations:
- Splash: Parabolic arc trajectories with pop-in fade
arcY = peakHeight * 4 * t * (1-t) for parabolic motion
- Snappy fade-in (12x rate), smooth fade-out (power 1.2)
- Lifetime: 0.6-1.2 seconds
- Bubble: Rising with lateral wobble
- Wobble frequency: direction * 4.0
- Drift pattern: sin(angle + t * wobbleFreq) * radius
- Lifetime: 1.2-2.5 seconds
- Shimmer: Surface sparkle with twinkle effect
- Fast twinkle using global time: sin(time8 + angle5) * sin(time13 + angle3)
- Circular wander pattern on water surface
- Lifetime: 1.5-3.0 seconds
- Ripple: Expanding ring with fade
- Phase-based scale: 0.15 + phase * 1.3
- Early fade (0-15%): linear ramp to 0.55 opacity
- Late fade (15-100%): power 1.5 decay from 0.55 to 0
- Continuous expansion driven by time uniform
Fishing Spot Variants:
- Net: Calm, gentle ripples (4 splash, 3 bubble, 3 shimmer, 2 ripples, burst every 5-10s)
- Bait: Medium activity (5 splash, 4 bubble, 4 shimmer, 2 ripples, burst every 3-7s)
- Fly: Active river fishing (8 splash, 5 bubble, 5 shimmer, 2 ripples, burst every 2-5s)
Burst System:
- Fish activity bursts fire 2-4 splash particles simultaneously
- Burst center randomized within 0.05-0.15 radius
- Burst particles have higher peak heights (0.25-0.6 vs 0.12-0.32)
- Burst particles cluster around burst center with 0.06 spread
- Burst timer resets to random interval after each burst
Usage Example
import { ParticleManager } from '@hyperscape/shared';
// ResourceSystem creates ParticleManager on client startup
const particleManager = new ParticleManager(scene);
// Register water particles (fishing spot)
particleManager.registerSpot({
entityId: 'entity-123',
position: { x: 10, y: 0, z: 20 },
resourceType: 'fishing_spot',
resourceId: 'fishing_spot_net'
});
// Move existing spot (when fishing spot relocates)
particleManager.moveSpot('entity-123', 'fishing_spot', { x: 12, y: 0, z: 22 });
// Per-frame update (called by ResourceSystem)
particleManager.update(deltaTime, camera);
// Cleanup on entity destroy
particleManager.unregisterSpot('entity-123', 'fishing_spot');
// Dispose all particle managers on system shutdown
particleManager.dispose();
Integration with ResourceSystem
The ParticleManager is created and managed by ResourceSystem on the client:
// From packages/shared/src/systems/shared/entities/ResourceSystem.ts
// CLIENT: Create centralized particle hub for all particle effects
if (!this.world.isServer) {
const scene = this.world.stage?.scene;
if (scene) {
this.particleManager = new ParticleManager(scene as any);
// Retroactively register any fishing spot entities created before this system started
const existingEntities = this.world.entities?.getByType?.("resource") || [];
for (const entity of existingEntities) {
if (
entity instanceof ResourceEntity &&
entity.config?.resourceType === "fishing_spot"
) {
entity.tryRegisterWithParticleManager();
}
}
}
// Listen for resource events and route them through the particle hub
this.subscribe(
EventType.RESOURCE_SPAWNED,
(data: { id?: string; type?: string; position?: { x: number; y: number; z: number } }) => {
this.particleManager?.handleResourceEvent(data);
}
);
}
// Per-frame update: drives all particle managers on the client
update(dt: number): void {
if (this.particleManager) {
const camera = this.world.camera;
if (camera) {
this.particleManager.update(dt, camera);
}
}
}
Lazy Registration Pattern
ResourceEntity uses a lazy registration pattern to handle timing issues where entities may be created before ResourceSystem starts:
// From packages/shared/src/entities/world/ResourceEntity.ts
private _registeredWithParticleManager = false;
public tryRegisterWithParticleManager(): boolean {
if (this._registeredWithParticleManager) return true;
const pm = this.getParticleManager();
if (!pm) return false;
const pos = this.getPosition();
pm.registerSpot({
entityId: this.id,
position: { x: pos.x, y: pos.y, z: pos.z },
resourceType: this.config.resourceType || "",
resourceId: this.config.resourceId || "",
});
this._registeredWithParticleManager = true;
return true;
}
protected clientUpdate(_deltaTime: number): void {
super.clientUpdate(_deltaTime);
// Lazy registration: retry if particle manager wasn't ready during createFishingSpotVisual
if (
!this._registeredWithParticleManager &&
this.config.resourceType === "fishing_spot"
) {
if (this.tryRegisterWithParticleManager()) {
console.log(`[FishingSpot] Late registration succeeded for ${this.id}`);
}
}
// Organic glow pulse — two frequencies layered for natural breathing
if (this.glowMesh) {
const now = Date.now();
const slow = Math.sin(now * 0.0015) * 0.04;
const fast = Math.sin(now * 0.004 + 1.3) * 0.02;
const pulse = 0.18 + slow + fast;
(this.glowMesh.material as THREE.MeshBasicMaterial).opacity = pulse;
}
}
Extensibility Guide
To add new particle types (fire, magic, dust, blood):
-
Create Sub-Manager Class: Create new file in
packages/shared/src/entities/managers/particleManager/
// Example: MagicParticleManager.ts
export class MagicParticleManager {
constructor(scene: THREE.Scene) { /* ... */ }
registerSpot(config: MagicSpotConfig): void { /* ... */ }
unregisterSpot(entityId: string): void { /* ... */ }
update(dt: number, camera: THREE.Camera): void { /* ... */ }
dispose(): void { /* ... */ }
}
-
Instantiate in ParticleManager: Add to constructor
export class ParticleManager {
private waterManager: WaterParticleManager;
private magicManager: MagicParticleManager; // Add new manager
constructor(scene: THREE.Scene) {
this.waterManager = new WaterParticleManager(scene);
this.magicManager = new MagicParticleManager(scene); // Instantiate
}
}
-
Add Routing Logic: Update register/unregister/move/handleEvent methods
registerSpot(config: ParticleSpotConfig): void {
if (this.isWaterType(config.resourceType)) {
this.waterManager.registerSpot({ /* ... */ });
return;
}
if (this.isMagicType(config.resourceType)) {
this.magicManager.registerSpot({ /* ... */ });
return;
}
}
-
Call update() and dispose(): Add to ParticleManager lifecycle methods
update(dt: number, camera: THREE.Camera): void {
this.waterManager.update(dt, camera);
this.magicManager.update(dt, camera); // Add update call
}
dispose(): void {
this.waterManager.dispose();
this.magicManager.dispose(); // Add dispose call
}
Rendering System
WebGPU-Only Architecture
As of February 2026, all rendering uses WebGPU exclusively:
// From utils/rendering/RendererFactory.ts
export type RendererBackend = "webgpu"; // WebGL removed
export type UniversalRenderer = WebGPURenderer;
// Create WebGPU renderer (no fallback)
export async function createRenderer(options?: RendererOptions): Promise<WebGPURenderer> {
if (!navigator.gpu) {
throw new Error('WebGPU not supported. Please use Chrome 113+, Edge 113+, or Safari 18+');
}
const renderer = new THREE.WebGPURenderer({
antialias: options?.antialias ?? true,
powerPreference: options?.powerPreference ?? "high-performance",
});
await renderer.init();
return renderer;
}
Breaking Change (Commit 47782ed, Feb 2026):
- Removed all WebGL detection and fallback code
- Removed
isWebGLAvailable(), isWebGLForced(), canTransferCanvas() functions
RendererBackend type is now only "webgpu" (removed "webgl")
- All shaders use TSL (Three Shading Language) which only compiles to WebGPU
Key Exports
Entity Hierarchy
The entity system follows a clear inheritance pattern:
// From packages/shared/src/entities/Entity.ts (lines 26-38)
// Entity (base class)
// ├── InteractableEntity (can be interacted with)
// │ ├── ResourceEntity (trees, rocks, fishing spots)
// │ ├── ItemEntity (ground items)
// │ └── NPCEntity (dialogue, shops)
// ├── CombatantEntity (can fight)
// │ ├── PlayerEntity (base player)
// │ │ ├── PlayerLocal (client-side local player)
// │ │ └── PlayerRemote (client-side remote players)
// │ └── MobEntity (enemies)
// └── HeadstoneEntity (player death markers)
| Export | Purpose |
|---|
Entity | Base entity class with components, physics, networking |
CombatantEntity | Combat-capable entities with health, damage |
InteractableEntity | Objects players can interact with |
PlayerEntity | Server-side player with full stats, inventory |
PlayerLocal | Client-side local player with input handling |
PlayerRemote | Client-side representation of other players |
MobEntity | NPCs and hostile mobs with AI states |
HeadstoneEntity | Death marker for item retrieval |
Systems (Shared)
Located in src/systems/shared/:
| Category | Systems |
|---|
character/ | Character stats, equipment, skills |
combat/ | Combat mechanics, damage, death |
death/ | Death handling, respawn |
economy/ | Banks, shops, trading |
entities/ | Entity lifecycle management |
interaction/ | Player-world interactions |
movement/ | Tile-based movement, collision, pathfinding |
presentation/ | Visual rendering |
world/ | World management |
Collision System
The movement system includes a unified collision matrix for OSRS-accurate tile blocking:
// From packages/shared/src/systems/shared/movement/
export { CollisionMatrix } from './CollisionMatrix';
export { CollisionFlag, CollisionMask } from './CollisionFlags';
export { EntityOccupancyMap } from './EntityOccupancyMap';
export { TileSystem } from './TileSystem';
Key Exports:
| Export | Purpose |
|---|
CollisionMatrix | Zone-based collision storage (8×8 tile zones) |
CollisionFlag | Bitmask flags (BLOCKED, WATER, OCCUPIED, walls) |
CollisionMask | Combined masks (BLOCKS_WALK, BLOCKS_MOVEMENT) |
EntityOccupancyMap | Entity tracking facade over CollisionMatrix |
tilesWithinRangeOfFootprint | Multi-tile interaction range checks |
Usage:
import { CollisionMatrix, CollisionMask } from '@hyperscape/shared';
const collision = new CollisionMatrix();
// Check if tile is walkable
if (!collision.hasFlags(x, z, CollisionMask.BLOCKS_WALK)) {
// Safe to move
}
// Add blocking for a tree
collision.addFlags(x, z, CollisionFlag.BLOCKED);
Data Manifests
Game content is defined in TypeScript files in src/data/:
| File | Purpose |
|---|
DataManager.ts | Runtime loader for manifests |
npcs.ts | NPC/mob definitions with drops, stats |
items.ts | Item definitions with stats, requirements |
world-areas.ts | Zone configuration |
banks-stores.ts | Shop inventories and bank locations |
avatars.ts | VRM avatar options |
playerEmotes.ts | Emote animation URLs |
world-structure.ts | World layout and spawn points |
equipment-stats.json | Equipment bonuses |
equipment-requirements.json | Level requirements |
NPC data is loaded from JSON manifests at runtime by DataManager.
Add new NPCs in world/assets/manifests/npcs.json.
Entry Points
Server (index.ts)
Exports for server-side usage:
import {
Entity,
DataManager,
PlayerEntity,
MobEntity
} from '@hyperscape/shared';
Client (index.client.ts)
Exports for client-side usage:
import {
Entity,
PlayerLocal,
PlayerRemote
} from '@hyperscape/shared/client';
Dependencies
| Package | Version | Purpose |
|---|
three | 0.183.1 | 3D rendering |
three-mesh-bvh | 0.9.8 | BVH raycasting optimization |
@pixiv/three-vrm | 3.4.3 | VRM avatar support |
@hyperscape/physx-js-webidl | workspace | Physics |
@hyperscape/procgen | workspace (devDep) | Procedural generation (type resolution only) |
react | ^19.2.0 | UI components |
styled-components | ^6.1.19 | CSS-in-JS |
fastify | ^5.0.0 | HTTP server (server builds) |
livekit-client | ^2.9.9 | Voice chat |
zod | 4.3.6 | Schema validation |
dotenv | 17.3.1 | Environment variables |
Procgen Dependency: The @hyperscape/procgen package is listed as a devDependency (not a regular dependency) to break the circular dependency cycle. This allows TypeScript to find module declarations during type checking without creating a build cycle in Turbo. The import still resolves at runtime since both packages are always installed together in the workspace.Commits: f355276, 3b9c0f2, 05c2892 (Feb 25-26, 2026)
Building
cd packages/shared
bun run build # Production build
bun run dev # Watch mode with auto-rebuild
Shared must be built before other packages that depend on it. Turbo handles this automatically.
Key Patterns
ECS Architecture
All game logic uses Entity Component System. See ECS Concepts.
Manifest-Driven Data
Game content defined in src/data/. See Manifests.
Type Safety
Strong TypeScript typing throughout—no any types allowed. ESLint enforces this rule.
Dual Entry Points
index.ts: Server-side exports (includes Fastify, database utilities)
index.client.ts: Client-side exports (browser-compatible)