Skip to main content

Client Application

The Hyperscape client is a React-based web application with Three.js WebGPU rendering. It provides a modern MMORPG experience with VRM avatars, responsive UI panels, and real-time multiplayer.
Client code lives in packages/client/src/. The rendering systems are in packages/shared/src/systems/client/.

Architecture Overview

┌─────────────────────────────────────────────────────────────────┐
│                       React Application                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
│  │ Auth Flow   │  │  Screens    │  │    Game Panels          │  │
│  │ PrivyAuth   │  │ - Login     │  │ - Inventory  - Skills   │  │
│  │ Wallet Auth │  │ - CharSel   │  │ - Equipment  - Bank     │  │
│  │             │  │ - GameClient│  │ - Combat     - Chat     │  │
│  └─────────────┘  └─────────────┘  └─────────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                    Three.js WebGPU Renderer                     │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
│  │ Scene Graph │  │  Cameras    │  │    Post-Processing      │  │
│  │ Terrain     │  │ Third Person│  │ - Bloom                 │  │
│  │ Entities    │  │ First Person│  │ - Tone Mapping          │  │
│  │ VRM Avatars │  │ RTS Mode    │  │ - Color Grading         │  │
│  └─────────────┘  └─────────────┘  └─────────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                      Client World                               │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────────┐  │
│  │  Network    │  │    Input    │  │     Systems             │  │
│  │  WebSocket  │  │  Keyboard   │  │ - Graphics  - Camera    │  │
│  │  Reconnect  │  │  Mouse      │  │ - Audio     - Loader    │  │
│  │             │  │  Touch      │  │ - Health    - XP Drops  │  │
│  └─────────────┘  └─────────────┘  └─────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

Entry Point

The client entry point is packages/client/src/index.tsx:
// Main entry point for the Hyperscape browser client
import { World, installThreeJSExtensions } from "@hyperscape/shared";
import React from "react";
import ReactDOM from "react-dom/client";

// Screens
import { LoginScreen } from "./screens/LoginScreen";
import { CharacterSelectScreen } from "./screens/CharacterSelectScreen";
import { GameClient } from "./screens/GameClient";

// Authentication (Privy + Wallet)
import { PrivyAuthProvider } from "./auth/PrivyAuthProvider";

// Embedded mode support for spectator views
import { EmbeddedGameClient } from "./components/EmbeddedGameClient";
import { isEmbeddedMode } from "./types/embeddedConfig";

Authentication Flow

Authentication uses Privy for wallet-based login:
Login Screen → Privy Modal → Wallet Signature → Character Select → Game
// auth/PrivyAuthProvider.tsx
export function PrivyAuthProvider({ children }: { children: React.ReactNode }) {
  return (
    <PrivyProvider
      appId={PRIVY_APP_ID}
      config={{
        loginMethods: ["wallet", "email", "google"],
        appearance: { theme: "dark" },
      }}
    >
      {children}
    </PrivyProvider>
  );
}

Screens

Login Screen

  • Wallet connection via Privy
  • Social login (email, Google)
  • Session persistence

Character Select Screen

  • List existing characters
  • Create new character
  • Character preview with VRM avatar

Game Client

  • Main game loop
  • World initialization
  • UI overlay rendering
// screens/GameClient.tsx
export function GameClient({ characterId }: { characterId: string }) {
  const worldRef = useRef<World | null>(null);
  
  useEffect(() => {
    const world = new World({
      isServer: false,
      systems: clientSystems,
    });
    
    world.connect({ characterId });
    worldRef.current = world;
    
    return () => world.destroy();
  }, [characterId]);
  
  return <CoreUI world={worldRef.current} />;
}

UI Components

Core UI (CoreUI.tsx)

The main UI wrapper that renders HUD elements and game panels:
export function CoreUI({ world }: { world: ClientWorld }) {
  const [ready, setReady] = useState(false);
  const [deathScreen, setDeathScreen] = useState(null);
  
  // Event handlers for loading, death, disconnect
  useEffect(() => {
    world.on(EventType.READY, () => setReady(true));
    world.on(EventType.PLAYER_DIED, handleDeath);
    world.on(EventType.DISCONNECTED, handleDisconnect);
  }, [world]);
  
  if (!ready) return <LoadingScreen />;
  
  return (
    <>
      <Sidebar world={world} />
      <StatusBars world={world} />
      <Chat world={world} />
      <ActionProgressBar world={world} />
      <XPProgressOrb world={world} />
      {deathScreen && <DeathScreen {...deathScreen} />}
    </>
  );
}

Game Panels

Located in packages/client/src/game/panels/:
PanelDescription
InventoryPanel.tsx28-slot inventory with drag & drop
EquipmentPanel.tsxEquipment slots with stat display
SkillsPanel.tsxAll skills with XP progress bars
CombatPanel.tsxCombat style selection
BankPanel.tsxBank interface with tabs
DialoguePanel.tsxNPC conversation UI
StorePanel.tsxShop/trading interface
SettingsPanel.tsxGame settings
LootWindow.tsxGround item pickup interface

HUD Components

Located in packages/client/src/game/hud/:
  • StatusBars.tsx - Health, prayer, run energy bars
  • XPProgressOrb.tsx - XP tracking orb with skill icons
  • ActionProgressBar.tsx - Skilling action progress
  • EntityContextMenu.tsx - Right-click context menus
  • Minimap.tsx - RS3/OSRS-accurate top-down map view with location icons
  • Nametags.ts - Entity name/level labels

Minimap System

The minimap provides RS3/OSRS-accurate navigation with location icons and entity tracking. Visual Standards (OSRS-accurate):
  • White dots: Other players
  • Yellow dots: NPCs, mobs, and buildings
  • Red dots: Ground items and loot
  • White square: Local player (distinct from other players)
  • Red flag: Destination marker (RS3-style)
Location Icons: The minimap displays icons for key locations instead of generic dots:
IconLocation TypeVisual
$BankGold coin symbol on gold circle
BagShopOpen-top bag silhouette
CrossPrayer AltarWhite cross (religious symbol)
RRunecrafting AltarPurple circle with “R”
AnvilSmithing StationDark anvil silhouette
FlameFurnaceOrange circle with flame
SteamCooking RangeBrown circle with steam lines
FishFishing SpotCyan circle with fish
PickaxeMining RockBrown circle with pickaxe
TreeWoodcuttingGreen circle
!Quest AvailableBlue circle with white ”!”
?Quest In ProgressBlue circle with white ”?”
Quest Icon States: Quest giver NPCs display state-aware icons based on quest progress:
// From Minimap.tsx
// Quest icon with state awareness
if (serviceTypes?.includes("quest")) {
  const questIds = npcConfig?.questIds;
  const statuses = questStatusesRef.current;
  
  if (questIds && questIds.length > 0 && statuses.size > 0) {
    let hasAvailable = false;
    let hasActive = false;
    let allCompleted = true;
    
    for (const qid of questIds) {
      const state = statuses.get(qid);
      if (state === "available") hasAvailable = true;
      else if (state === "active") hasActive = true;
      if (state !== "completed") allCompleted = false;
    }
    
    if (hasAvailable) {
      subType = "quest_available";  // Blue "!"
    } else if (hasActive) {
      subType = "quest_in_progress";  // Blue "?"
    }
    // If all completed, no quest icon (shows bank/shop icon if applicable)
  }
}
Quest Status Synchronization: The minimap fetches quest statuses from the server and updates icons in real-time:
// Fetch quest list from server
world.network?.send("getQuestList", {});

// Listen for quest status updates
world.network?.on("questList", (data) => {
  const mapped = data.quests.map((q) => ({
    id: q.id,
    state: mapStatus(q.status),  // "not_started" → "available", etc.
  }));
  
  // Update ref for synchronous access in entity loop
  questStatusesRef.current = new Map(mapped.map(q => [q.id, q.state]));
});

// Re-fetch on quest events
world.on(EventType.QUEST_STARTED, fetchQuestList);
world.on(EventType.QUEST_PROGRESSED, fetchQuestList);
world.on(EventType.QUEST_COMPLETED, fetchQuestList);
Icon Behavior:
  • Blue ”!”: NPC has at least one available quest (not started)
  • Blue ”?”: NPC has at least one quest in progress
  • No icon: All quests completed (shows bank/shop icon if NPC provides those services)
  • Icons disappear when all of an NPC’s quests are completed
NPC Configuration: Quest giver NPCs must have questIds field in their configuration:
{
  "id": "quest_giver_lumbridge",
  "name": "Quest Giver",
  "services": {
    "types": ["quest"],
    "questIds": ["cooks_assistant", "sheep_shearer"]
  }
}
The questIds field is passed through the spawn → network → client pipeline so the minimap can identify quest giver NPCs and display appropriate icons. Icon Detection: Icons are automatically assigned based on entity configuration:
// From Minimap.tsx
// Detect NPC service type for minimap icons
const npcConfig = entity.config;
const serviceTypes = npcConfig?.services;  // Fixed: services is string[], not { types: string[] }
if (serviceTypes?.includes("bank")) {
  subType = "bank";
} else if (serviceTypes?.includes("shop")) {
  subType = "shop";
}
// Quest icon detection (with state awareness) shown above

// Detect resource subtype for minimap icons
const resConfig = entity.config;
if (resConfig?.resourceType === "fishing_spot" || resConfig?.harvestSkill === "fishing") {
  subType = "fishing";
} else if (resConfig?.resourceType === "mining_rock" || resConfig?.harvestSkill === "mining") {
  subType = "mining";
} else if (resConfig?.resourceType === "tree" || resConfig?.harvestSkill === "woodcutting") {
  subType = "tree";
}
Bug Fix (PR #885): Fixed broken NPC service detection cast. The services field is string[], not { types: string[] }. This also fixed bank/shop icon detection which was previously broken.
Size Hierarchy:
  • Entity dots: 6px diameter (compact for clarity)
  • Location icons: 12px diameter (prominent for navigation)
Rendering:
  • 3D terrain rendered via orthographic camera (throttled to ~15fps for performance)
  • 2D overlay canvas for entity pips and icons (60fps for smooth interaction)
  • Cached projection-view matrix keeps pips synced with throttled 3D render
  • Pre-allocated vectors avoid GC pressure in render loop
Features:
  • Click-to-move on minimap
  • Scroll to zoom (20-1000 tile extent)
  • Rotates with main camera (RS3-style)
  • Destination marker persists until reached
  • Resizable and collapsible
  • Edit mode support for UI customization

3D Graphics System

WebGPU Renderer

The graphics system uses Three.js with WebGPU for high-performance rendering:
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+). Check compatibility at webgpureport.org.Breaking Change (Feb 2026): WebGL fallback was completely removed. All shaders use TSL which only compiles to WebGPU. Users on browsers without WebGPU will see an error screen with upgrade instructions.
WebGPU Initialization Timeouts: The renderer includes timeout protection to detect GPU driver issues:
// 30s timeout on adapter request
const adapter = await Promise.race([
  navigator.gpu.requestAdapter(),
  new Promise((_, reject) => 
    setTimeout(() => reject(new Error('WebGPU adapter request timed out')), 30000)
  )
]);

// 60s timeout on renderer initialization
await Promise.race([
  renderer.init(),
  new Promise((_, reject) => 
    setTimeout(() => reject(new Error('WebGPU renderer initialization timed out')), 60000)
  )
]);
These timeouts help diagnose misconfigured GPU servers where WebGPU initialization hangs indefinitely.
// systems/client/ClientGraphics.ts
export class ClientGraphics extends SystemBase {
  private renderer: WebGPURenderer;
  private postProcessing: PostProcessingComposer;
  
  async init() {
    // WebGPU is required - no fallback
    if (!navigator.gpu) {
      throw new Error('WebGPU not supported. Please use Chrome 113+, Edge 113+, or Safari 18+');
    }
    
    this.renderer = await createRenderer({
      powerPreference: "high-performance",
      antialias: true,
    });
    
    // Configure shadows (Cascaded Shadow Maps)
    configureShadowMaps(this.renderer, {
      cascades: 3,
      shadowMapSize: 2048,
    });
    
    // Post-processing (TSL-based, WebGPU only)
    this.postProcessing = createPostProcessing(this.renderer, {
      bloom: true,
      toneMapping: true,
      colorGrading: true,
    });
  }
}
Post-Processing Pipeline: The post-processing system uses TSL (Three Shading Language) for all effects:
// From ClientGraphics.ts
// All post-processing uses TSL node materials (WebGPU only)
this.composer.render();

// LUT color grading with intensity control
if (!this.colorGradingEnabled) {
  this.lutPass.intensity = 0.0;
}
Post-Processing Effects (TSL-based):
  • Bloom: Glow effects for magical items and particles
  • Tone Mapping: HDR to LDR conversion with auto-exposure
  • Color Grading: LUT-based color correction
  • Outline: Entity highlighting for hover/selection
Fix Applied (PR #829):
  • Issue: When LUT was “none” but hover triggered outline, stale LUT data leaked into rendering
  • Solution: Zero LUT intensity when disabled so outline-only rendering stays clean
  • Benefit: Entity highlights work correctly even when color grading is disabled

Rendering Pipeline

  1. Pre-render: Update matrices, frustum culling
  2. Shadow Pass: Render cascaded shadow maps (CSM)
  3. Main Pass: Render scene with deferred lighting
  4. Post-Processing: Bloom, tone mapping, color grading (TSL-based)
  5. UI Overlay: Render 2D React UI on top
All rendering uses WebGPU - there is no WebGL fallback path.

Model Loading & Transform Baking

The ModelCache system handles GLTF model loading with transform baking to prevent rendering issues:
// From ModelCache.ts
private bakeTransformsToGeometry(scene: THREE.Object3D): void {
  // Ensure all matrices are up to date
  scene.updateMatrixWorld(true);

  // Apply transforms to each mesh's geometry
  scene.traverse((child) => {
    if (child instanceof THREE.Mesh && child.geometry) {
      // Clone geometry to avoid modifying shared geometry
      child.geometry = child.geometry.clone();

      // Apply world matrix to geometry (Three.js built-in method)
      child.geometry.applyMatrix4(child.matrixWorld);

      // Reset transform to identity
      child.position.set(0, 0, 0);
      child.rotation.set(0, 0, 0);
      child.scale.set(1, 1, 1);
      child.updateMatrix();
    }
  });
}
Why Transform Baking? GLTF files can have transforms stored in various ways:
  • Position/rotation/scale properties
  • Baked into matrices
  • Non-decomposable transforms (shear)
Baking all transforms into vertex positions guarantees correct rendering regardless of how the GLTF was exported from Blender or other 3D tools. Quaternion Normalization: Entity rotations use quaternions with all four components (x, y, z, w):
// From Entity.ts
quaternion: config.rotation
  ? [
      config.rotation.x,
      config.rotation.y,
      config.rotation.z,
      config.rotation.w,  // Uses actual w value, not hardcoded 1
    ]
  : undefined,
This prevents “squished” or incorrectly rotated models that can occur when quaternion components are not properly normalized.

Camera System

Supports multiple camera modes:
// systems/client/ClientCameraSystem.ts
export class ClientCameraSystem extends SystemBase {
  private settings = {
    minDistance: 2.0,        // Min zoom
    maxDistance: 15.0,       // Max zoom
    minPolarAngle: Math.PI * 0.35,  // Pitch limits
    maxPolarAngle: Math.PI * 0.48,
    rotateSpeed: 0.9,        // RS3-like feel
    zoomSpeed: 1.2,
    shoulderOffsetMax: 0.15, // Over-the-shoulder offset
  };
  
  // Camera spherical coordinates
  private theta = Math.PI;   // Horizontal angle (Math.PI = behind player)
  private phi = Math.PI * 0.4;  // Vertical angle (pitch)
  private radius = 5.0;      // Distance from target
}
Camera Initialization Fix (PR #829): The camera now correctly initializes with theta=Math.PI for standard third-person behind-the-player view. Previously, theta=0 placed the camera in front of the player, causing backwards movement on fresh load.
ModeControls
Third PersonRight-drag to rotate, scroll to zoom, click-to-move
First PersonPointer lock, WASD movement
Top-down/RTSPan, zoom, click-to-move

VRM Avatar System

Characters use VRM format avatars with humanoid bone mapping:
// components/CharacterPreview.tsx
import { VRM, VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
import { retargetAnimationToVRM } from "../utils/vrmAnimationRetarget";

export const CharacterPreview: React.FC<{ vrmUrl: string }> = ({ vrmUrl }) => {
  const vrmRef = useRef<VRM | null>(null);
  const mixerRef = useRef<THREE.AnimationMixer | null>(null);
  
  useEffect(() => {
    const loader = new GLTFLoader();
    loader.register((parser) => new VRMLoaderPlugin(parser));
    
    loader.load(vrmUrl, async (gltf) => {
      const vrm = gltf.userData.vrm as VRM;
      VRMUtils.rotateVRM0(vrm);  // Fix rotation for VRM 0.x
      
      // Retarget animations to VRM bones
      const mixer = new THREE.AnimationMixer(vrm.scene);
      const idleClip = await loadAnimation("idle.glb");
      const retargeted = retargetAnimationToVRM(idleClip, vrm);
      mixer.clipAction(retargeted).play();
      
      vrmRef.current = vrm;
      mixerRef.current = mixer;
    });
  }, [vrmUrl]);
};

VRM Bone Mapping

type VRMHumanBoneName =
  | "hips" | "spine" | "chest" | "upperChest" | "neck" | "head"
  | "leftShoulder" | "leftUpperArm" | "leftLowerArm" | "leftHand"
  | "rightShoulder" | "rightUpperArm" | "rightLowerArm" | "rightHand"
  | "leftUpperLeg" | "leftLowerLeg" | "leftFoot" | "leftToes"
  | "rightUpperLeg" | "rightLowerLeg" | "rightFoot" | "rightToes";

Client Systems

Located in packages/shared/src/systems/client/:
SystemDescription
ClientGraphics.tsWebGPU rendering, shadows, post-processing
ClientCameraSystem.tsCamera controls and collision
ClientNetwork.tsWebSocket connection, reconnection
ClientInput.tsKeyboard, mouse, touch handling
ClientAudio.ts3D positional audio, music
ClientLoader.tsAsset loading with progress
HealthBars.tsFloating health bars over entities
Nametags.tsEntity name labels
DamageSplatSystem.tsFloating damage numbers
XPDropSystem.tsXP gain notifications
EquipmentVisualSystem.tsEquipment rendering on avatars
TileInterpolator.tsSmooth tile-based movement

Embedded Mode

For stream overlays and spectator views:
// URL params for embedded mode
?embedded=true
&mode=spectator|free
&agentId=AGENT_ID
&followEntity=ENTITY_ID
&quality=low|medium|high
&hiddenUI=inventory,skills,chat
// components/EmbeddedGameClient.tsx
export function EmbeddedGameClient() {
  const config = window.__HYPERSCAPE_CONFIG__;
  
  return (
    <World
      mode={config.mode}
      followEntity={config.followEntity}
      hiddenUI={config.hiddenUI}
    />
  );
}