Skip to main content

Networking Architecture

Hyperscape uses a server-authoritative architecture with WebSocket-based real-time communication. The server runs at 600ms ticks while clients render at 60 FPS using prediction and interpolation.
Network code lives in packages/shared/src/systems/client/ClientNetwork.ts (2640+ lines) and packages/server/src/systems/ServerNetwork/.

Architecture Overview

Connection URLs

EnvironmentWebSocket URLHTTP API URL
Developmentws://localhost:5555/wshttp://localhost:5555
Productionwss://api.hyperscape.club/wshttps://api.hyperscape.club
Stagingwss://staging-api.hyperscape.club/wshttps://staging-api.hyperscape.club

CORS Configuration

The server automatically allows connections from:
  • Production: https://hyperscape.club, https://www.hyperscape.club
  • Staging: https://staging.hyperscape.club
  • Preview: https://*.hyperscape.pages.dev
  • Development: http://localhost:* (any port)
See Configuration for full CORS details.
LayerRatePurpose
Server Tick600ms (1.67 Hz)Authoritative game logic
Network Sync125ms (8 Hz)Entity state broadcasts
Client Render16.7ms (60 FPS)Visual interpolation
Client Input33ms (30 Hz)Movement/action requests

Binary Protocol

Communication uses msgpackr binary serialization for efficiency.

Packet Format

// From platform/shared/packets.ts
export function writePacket(name: string, data: unknown): ArrayBuffer {
  const id = PACKET_ID_MAP[name];
  const payload = pack(data);
  const buffer = new ArrayBuffer(1 + payload.byteLength);
  new Uint8Array(buffer)[0] = id;
  new Uint8Array(buffer).set(new Uint8Array(payload), 1);
  return buffer;
}

export function readPacket(buffer: ArrayBuffer): { name: string; data: unknown } {
  const id = new Uint8Array(buffer)[0];
  const name = PACKET_NAMES[id];
  const data = unpack(new Uint8Array(buffer.slice(1)));
  return { name, data };
}

Entity Update Optimization

Entity updates use abbreviated keys to minimize bandwidth:
KeyFull NameTypeDescription
pposition[x, y, z]World coordinates
qquaternion[x, y, z, w]Rotation
vvelocity[x, y, z]Movement vector
eemotestringCurrent emote
hhealth{ current, max }HP state
iidstringEntity ID
ttypestringEntity type

Example Packet

// entityModified packet payload
{
  i: "player_abc123",
  t: "player",
  p: [125.5, 10.0, -42.3],
  q: [0, 0.707, 0, 0.707],
  h: { current: 45, max: 99 },
  e: "wave"
}

Packet Types

Client → Server

PacketPurposePayload
moveRequestRequest movement{ x, z, running }
attackEntityAttack target{ targetId }
changeCombatStyleChange style{ style }
pickupItemPick up ground item{ itemId }
useItemUse inventory item{ itemId, slot }
equipItemEquip item{ itemId }
dropItemDrop item{ itemId, quantity }
bankDepositDeposit to bank{ itemId, quantity }
bankWithdrawWithdraw from bank{ itemId, quantity }
chatMessageSend chat{ message }
chopTreeStart woodcutting{ treeId }
catchFishStart fishing{ spotId }
lightFireLight fire{ logId }
cookFoodCook food{ foodId, fireId }
getQuestListFetch quest list{}
getQuestDetailFetch quest details{ questId }
questAcceptAccept quest{ questId }
xpLampUseUse XP lamp{ itemId, slot, skillId, xpAmount }
dialogueContinueContinue dialogue{ npcId }

Server → Client

PacketPurposePayload
initConnection setup{ playerId, worldState }
snapshotFull world state{ entities[], tick }
entityAddedNew entityEntityData
entityModifiedEntity update{ id, ...changes }
entityRemovedEntity despawn{ id }
inventoryUpdatedInventory change{ items[], coins }
equipmentUpdatedEquipment change{ slots }
skillsUpdatedXP/level change{ skills }
chatMessageIncoming chat{ sender, message }
damageDealtCombat damage{ targetId, damage, didHit }
deathNotificationEntity died{ entityId, killerId }
questListQuest list response{ quests[], questPoints }
questDetailQuest detail response{ id, name, status, stages[], ... }
questStartConfirmQuest accept screen{ questId, questName, requirements, rewards }
questProgressedQuest progress update{ questId, stage, progress }
questCompletedQuest completion{ questId, questName, rewards }
xpDropXP gain notification{ skill, xpGained, newXp, newLevel }

Client-Side Prediction

The client predicts movement locally for responsive controls, then reconciles with server authority.

Prediction Flow

// From PlayerLocal.ts
class PlayerLocal {
  private pendingMoves: MoveRequest[] = [];
  private lastServerPosition: Position3D;

  fixedUpdate(delta: number): void {
    // 1. Run local physics simulation
    this.physics.step(delta);

    // 2. Store pending move for reconciliation
    this.pendingMoves.push({
      tick: this.world.currentTick,
      position: this.position.clone(),
      input: this.currentInput,
    });

    // 3. Send to server
    this.network.send('moveRequest', {
      x: this.targetPosition.x,
      z: this.targetPosition.z,
      running: this.isRunning,
    });
  }

  updateServerPosition(serverPos: Position3D, serverTick: number): void {
    // 4. Remove acknowledged moves
    this.pendingMoves = this.pendingMoves.filter(m => m.tick > serverTick);

    // 5. Check prediction error
    const error = this.position.distanceTo(serverPos);

    if (error > 0.5) {
      // 6. Snap to server position if too far off
      this.position.copy(serverPos);

      // 7. Replay pending moves
      for (const move of this.pendingMoves) {
        this.applyMove(move.input);
      }
    }
  }
}

Interpolation for Remote Entities

// From TileInterpolator.ts
class TileInterpolator {
  private snapshots: EntitySnapshot[] = []; // Buffer of last 3 positions
  private snapshotIndex = 0;

  addSnapshot(position: Position3D, rotation: Quaternion, timestamp: number): void {
    this.snapshots[this.snapshotIndex] = { position, rotation, timestamp };
    this.snapshotIndex = (this.snapshotIndex + 1) % 3;
  }

  interpolate(alpha: number): { position: Position3D; rotation: Quaternion } {
    const prev = this.snapshots[this.snapshotIndex];
    const next = this.snapshots[(this.snapshotIndex + 1) % 3];

    return {
      position: prev.position.clone().lerp(next.position, alpha),
      rotation: prev.rotation.clone().slerp(next.rotation, alpha),
    };
  }
}

Server Network System

The server handles all authoritative game logic.

Connection Flow

// From ServerNetwork/index.ts
class ServerNetwork extends SystemBase {
  private sockets: Map<string, WebSocket> = new Map();

  onConnection(socket: WebSocket, query: { token: string }): void {
    // 1. Authenticate via Privy JWT
    const userId = await this.auth.verify(query.token);

    // 2. Load or create character
    const character = await this.db.characters.findOrCreate(userId);

    // 3. Spawn player entity
    const player = this.world.spawnEntity({
      type: 'player',
      id: character.id,
      position: character.position,
      stats: character.stats,
    });

    // 4. Send init packet
    socket.send(writePacket('init', {
      playerId: player.id,
      worldState: this.world.serialize(),
    }));

    // 5. Register socket
    this.sockets.set(player.id, socket);

    // 6. Emit event for other systems
    this.world.emit(EventType.PLAYER_CONNECTED, { playerId: player.id });
  }

  onDisconnect(socket: WebSocket): void {
    const playerId = this.getPlayerIdBySocket(socket);

    // 1. Save character to database
    this.saveManager.savePlayer(playerId);

    // 2. Despawn player entity
    this.world.removeEntity(playerId);

    // 3. Notify other players
    this.broadcast('entityRemoved', { id: playerId });

    // 4. Cleanup
    this.sockets.delete(playerId);
  }
}

Packet Handlers

// From ServerNetwork/index.ts
private registerHandlers(): void {
  this.on('moveRequest', this.handleMoveRequest.bind(this));
  this.on('attackEntity', this.handleAttackEntity.bind(this));
  this.on('pickupItem', this.handlePickupItem.bind(this));
  this.on('useItem', this.handleUseItem.bind(this));
  this.on('equipItem', this.handleEquipItem.bind(this));
  this.on('dropItem', this.handleDropItem.bind(this));
  this.on('bankDeposit', this.handleBankDeposit.bind(this));
  this.on('bankWithdraw', this.handleBankWithdraw.bind(this));
  this.on('chatMessage', this.handleChatMessage.bind(this));
  this.on('chopTree', this.handleChopTree.bind(this));
  this.on('catchFish', this.handleCatchFish.bind(this));
  // ... more handlers
}

private handleMoveRequest(playerId: string, data: { x: number; z: number; running: boolean }): void {
  const player = this.world.entities.get(playerId);
  if (!player) return;

  // Validate and set path
  const movementSystem = this.world.getSystem('movement');
  movementSystem.setPlayerDestination(playerId, data.x, data.z, data.running);
}

Event Bridge

The EventBridge converts game events to network packets automatically.
// From ServerNetwork/event-bridge.ts
class EventBridge {
  constructor(world: World, network: ServerNetwork) {
    // Inventory changes → inventoryUpdated packet
    world.on(EventType.INVENTORY_UPDATED, (data) => {
      network.sendTo(data.playerId, 'inventoryUpdated', {
        items: data.items,
        coins: data.coins,
      });
    });

    // Equipment changes → equipmentUpdated packet
    world.on(EventType.EQUIPMENT_UPDATED, (data) => {
      network.sendTo(data.playerId, 'equipmentUpdated', {
        slots: data.slots,
      });
    });

    // Combat damage → damageDealt packet
    world.on(EventType.COMBAT_DAMAGE, (data) => {
      network.broadcast('damageDealt', {
        attackerId: data.attackerId,
        targetId: data.targetId,
        damage: data.damage,
        didHit: data.didHit,
      });
    });

    // ... 50+ more event mappings
  }
}

Network Constants

// Network configuration
export const NETWORK_CONSTANTS = {
  // Tick and sync rates
  TICK_DURATION_MS: 600,          // Server tick interval
  NETWORK_RATE: 125,              // 8 Hz entity sync
  INPUT_RATE: 33,                 // 30 Hz client input

  // Interpolation
  SNAPSHOT_BUFFER_SIZE: 3,        // Snapshots to buffer
  INTERPOLATION_DELAY_MS: 100,    // Delay for smooth interpolation

  // Prediction
  MAX_PREDICTION_ERROR: 0.5,      // Units before snap correction
  MAX_PENDING_MOVES: 10,          // Moves to buffer for reconciliation

  // Connection
  PING_INTERVAL_MS: 5000,         // Latency measurement interval
  RECONNECT_DELAY_MS: 1000,       // Initial reconnect delay
  MAX_RECONNECT_DELAY_MS: 30000,  // Max backoff delay
  RECONNECT_MULTIPLIER: 2,        // Exponential backoff factor

  // Timeouts
  CONNECTION_TIMEOUT_MS: 10000,   // Max time to connect
  IDLE_TIMEOUT_MS: 300000,        // Disconnect after 5 min idle
};

Rate Limiting

Network handlers use sliding window rate limiters to prevent abuse:
// From ServerNetwork/services/SlidingWindowRateLimiter.ts

// Quest handlers
getQuestListRateLimiter()    // 5 requests/sec
getQuestDetailRateLimiter()  // 10 requests/sec
getQuestAcceptRateLimiter()  // 3 requests/sec

// Inventory handlers
getPickupRateLimiter()       // 5 requests/sec
getConsumeRateLimiter()      // 3 requests/sec
getCoinPouchRateLimiter()    // 5 requests/sec

// Combat handlers
getAttackRateLimiter()       // 10 requests/sec
getPrayerRateLimiter()       // 5 requests/sec

// Movement handlers
getMoveRateLimiter()         // 30 requests/sec
getFollowRateLimiter()       // 5 requests/sec
Features:
  • Per-player tracking
  • Sliding window algorithm (more accurate than fixed window)
  • Automatic stale entry cleanup
  • Configurable cleanup interval (60s default)
Example:
// Handler with rate limiting
export function handleQuestAccept(socket: ServerSocket, data: { questId: string }, world: World): void {
  const playerId = getPlayerId(socket);
  
  // Rate limit check
  if (!getQuestAcceptRateLimiter().check(playerId)) {
    logger.debug(`Rate limit exceeded for ${playerId} on questAccept`);
    return;
  }
  
  // Process request...
}