Skip to main content

Event System

Hyperscape uses a strongly-typed event system for decoupled inter-system communication. Over 500 event types enable systems to communicate without direct dependencies.
Event types are defined in packages/shared/src/types/events/events.ts.

Architecture

Systems emit events instead of calling each other directly. This ensures:
  • Loose coupling - Systems don’t know about each other
  • Extensibility - Add listeners without modifying emitters
  • Testability - Mock events for unit tests
  • Replay - Events can be recorded and replayed

Event Types

Events are organized by category in the EventType enum:

Combat Events

export enum EventType {
  // Combat lifecycle
  COMBAT_START = "combat:start",
  COMBAT_END = "combat:end",
  COMBAT_ATTACK = "combat:attack",
  COMBAT_KILL = "combat:kill",

  // Damage
  COMBAT_DAMAGE = "combat:damage",
  ENTITY_DAMAGED = "entity:damaged",
  ENTITY_HEALED = "entity:healed",

  // Death
  ENTITY_DEATH = "entity:death",
  PLAYER_DEATH = "player:death",
  MOB_DEATH = "mob:death",

  // Combat level
  COMBAT_LEVEL_CHANGED = "combat:level_changed",
}

Entity Events

export enum EventType {
  // Lifecycle
  ENTITY_SPAWNED = "entity:spawned",
  ENTITY_DESTROYED = "entity:destroyed",
  ENTITY_COMPONENT_ADDED = "entity:component_added",
  ENTITY_COMPONENT_REMOVED = "entity:component_removed",

  // State changes
  ENTITY_HEALTH_CHANGED = "entity:health_changed",
  ENTITY_LEVEL_CHANGED = "entity:level_changed",
  ENTITY_INTERACTED = "entity:interacted",
  ENTITY_INTERACT = "entity:interact",
}

Inventory Events

export enum EventType {
  // Item operations
  INVENTORY_ADD = "inventory:add",
  INVENTORY_REMOVE = "inventory:remove",
  INVENTORY_UPDATED = "inventory:updated",
  INVENTORY_FULL = "inventory:full",

  // Equipment
  EQUIPMENT_CHANGED = "equipment:changed",
  EQUIPMENT_UPDATED = "equipment:updated",
  ITEM_EQUIPPED = "item:equipped",
  ITEM_UNEQUIPPED = "item:unequipped",

  // Ground items
  ITEM_DROPPED = "item:dropped",
  ITEM_PICKED_UP = "item:picked_up",
  ITEM_SPAWNED = "item:spawned",
  ITEM_DESPAWNED = "item:despawned",
}

Skill Events

export enum EventType {
  // XP and leveling
  SKILLS_XP_GAINED = "skills:xp_gained",
  SKILLS_LEVEL_UP = "skills:level_up",
  SKILLS_UPDATED = "skills:updated",
  SKILLS_MILESTONE = "skills:milestone",
  SKILLS_ACTION = "skills:action",
  SKILLS_RESET = "skills:reset",

  // Totals
  TOTAL_LEVEL_CHANGED = "total_level:changed",
}

Player Events

export enum EventType {
  // Connection
  PLAYER_CONNECTED = "player:connected",
  PLAYER_DISCONNECTED = "player:disconnected",
  PLAYER_REGISTERED = "player:registered",

  // State
  PLAYER_SPAWNED = "player:spawned",
  PLAYER_MOVED = "player:moved",
  PLAYER_STOPPED = "player:stopped",

  // Actions
  PLAYER_ATTACK = "player:attack",
  PLAYER_CHAT = "player:chat",
}

Resource Events

export enum EventType {
  // Gathering
  RESOURCE_GATHERED = "resource:gathered",
  RESOURCE_DEPLETED = "resource:depleted",
  RESOURCE_RESPAWNED = "resource:respawned",
  
  // Gathering tool visuals (OSRS-style)
  GATHERING_TOOL_SHOW = "gathering:tool:show",
  GATHERING_TOOL_HIDE = "gathering:tool:hide",

  // Processing
  RESOURCE_PROCESSED = "resource:processed",
  FIRE_LIT = "fire:lit",
  FOOD_COOKED = "food:cooked",
  
  // Gathering tool visuals (OSRS-style)
  GATHERING_TOOL_SHOW = "gathering:tool:show",
  GATHERING_TOOL_HIDE = "gathering:tool:hide",
}
Gathering Tool Events:
interface GatheringToolShowEvent {
  playerId: string;
  itemId: string;  // Tool item ID (e.g., "fishing_rod")
  slot: string;    // Equipment slot to show in (e.g., "weapon")
}

interface GatheringToolHideEvent {
  playerId: string;
  slot: string;    // Equipment slot to hide from
}
Gathering tool events enable OSRS-style visuals where tools appear in hand during gathering (e.g., fishing rod during fishing) even though they’re in inventory, not equipped.

Bank Events

export enum EventType {
  BANK_OPENED = "bank:opened",
  BANK_CLOSED = "bank:closed",
  BANK_DEPOSIT = "bank:deposit",
  BANK_WITHDRAW = "bank:withdraw",
  BANK_UPDATED = "bank:updated",
}

Quest Events

export enum EventType {
  // Quest lifecycle
  QUEST_START_CONFIRM = "quest:start_confirm",
  QUEST_STARTED = "quest:started",
  QUEST_PROGRESSED = "quest:progressed",
  QUEST_COMPLETED = "quest:completed",
  
  // XP Lamp (quest reward item)
  XP_LAMP_USE_REQUEST = "xp_lamp:use_request",
  XP_LAMP_SKILL_SELECTED = "xp_lamp:skill_selected",
  XP_LAMP_APPLIED = "xp_lamp:applied",
}

Usage

Emitting Events

// From any system
this.world.emit(EventType.INVENTORY_UPDATED, {
  playerId: player.id,
  items: inventory.items,
  coins: inventory.coins,
});

Subscribing to Events

// In SystemBase subclass
async init(): Promise<void> {
  this.subscribe<InventoryUpdatedEvent>(
    EventType.INVENTORY_UPDATED,
    (data) => this.handleInventoryUpdate(data)
  );
}

private handleInventoryUpdate(data: InventoryUpdatedEvent): void {
  // Handle the event
  console.log(`Player ${data.playerId} inventory changed`);
}

Typed Event Data

Each event type has a corresponding data interface:
interface CombatDamageEvent {
  attackerId: string;
  targetId: string;
  damage: number;
  didHit: boolean;
  attackType: AttackType;
  isCritical: boolean;
}

interface SkillsXPGainedEvent {
  playerId: string;
  skill: keyof Skills;
  amount: number;
}

interface InventoryUpdatedEvent {
  playerId: string;
  items: InventoryItem[];
  coins: number;
}

interface EntityDeathEvent {
  entityId: string;
  killerId: string | null;
  position: Position3D;
  drops: Array<{ itemId: string; quantity: number }>;
}

Event Bridge

The EventBridge automatically converts game events to network packets:
// From ServerNetwork/event-bridge.ts
class EventBridge {
  constructor(world: World, network: ServerNetwork) {
    // Map game events to network packets

    // Inventory → inventoryUpdated packet
    world.on(EventType.INVENTORY_UPDATED, (data) => {
      network.sendTo(data.playerId, 'inventoryUpdated', {
        items: data.items,
        coins: data.coins,
      });
    });

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

    // Skill XP → skillsUpdated packet
    world.on(EventType.SKILLS_UPDATED, (data) => {
      network.sendTo(data.playerId, 'skillsUpdated', {
        skills: data.skills,
      });
    });

    // Entity death → deathNotification packet
    world.on(EventType.ENTITY_DEATH, (data) => {
      network.broadcast('deathNotification', {
        entityId: data.entityId,
        killerId: data.killerId,
      });
    });

    // 50+ more event-to-packet mappings...
  }
}

Event Flow Example

Here’s how a player picking up an item flows through the system:

Best Practices

1

Use Typed Events

Always define interfaces for event data to catch type errors at compile time.
2

Keep Handlers Fast

Event handlers should complete quickly. Offload heavy work to async tasks.
3

Avoid Circular Emissions

Don’t emit events that trigger handlers that emit the same event.
4

Clean Up Subscriptions

Call unsubscribe() or use autoCleanup: true in SystemBase config.