Skip to main content

Death & Respawn System

The death system handles player deaths with zone-aware mechanics, crash recovery, gravestone spawning, and secure item recovery. It uses database-first persistence with atomic operations to prevent item duplication and loss.
Death code lives in:
  • packages/shared/src/systems/shared/combat/PlayerDeathSystem.ts - Main death orchestrator (1,263 lines)
  • packages/shared/src/systems/shared/death/DeathStateManager.ts - Death persistence (368 lines)
  • packages/shared/src/systems/shared/death/SafeAreaDeathHandler.ts - Safe zone logic (322 lines)
  • packages/shared/src/systems/shared/death/WildernessDeathHandler.ts - PvP zone logic (130 lines)
  • packages/shared/src/systems/shared/death/ZoneDetectionSystem.ts - Zone detection (213 lines)
  • packages/server/src/database/repositories/DeathRepository.ts - Database operations

Death Zones

type ZoneType = 
  | "safe_area"   // Town, bank areas - gravestone system
  | "wilderness"  // PvE wilderness - immediate ground items
  | "pvp_zone"    // PvP zones - items lootable by killer
  | "unknown";    // Fallback (treated as safe_area)

Crash Recovery System

The death system includes comprehensive crash recovery to prevent item loss during server restarts.

Database-First Persistence

Death locks are created in the database before clearing inventory, ensuring items are always recoverable:
// From PlayerDeathSystem.ts
await databaseSystem.executeInTransaction(async (tx) => {
  // 1. Create death lock with full item list (crash-safe)
  const acquired = await this.deathStateManager.createDeathLock(
    playerId,
    {
      items: itemsToDrop,  // Full item data for recovery
      killedBy: sanitizeKilledBy(killedBy),
      position: deathPosition,
      zoneType,
      // ...
    },
    tx
  );
  
  // 2. Clear inventory/equipment (atomic)
  await inventorySystem.clearInventoryImmediate(playerId);
  await equipmentSystem.clearEquipmentAndReturn(playerId, tx);
}, { isolationLevel: 'serializable' });

Recovery on Startup

When the server starts, it automatically recovers unfinished deaths:
// From PlayerDeathSystem.ts - init()
async init(): Promise<void> {
  // Recover deaths that weren't completed before crash
  await this.recoverUnfinishedDeaths();
}

private async recoverUnfinishedDeaths(): Promise<void> {
  const unrecoveredDeaths = await this.databaseSystem.getUnrecoveredDeathsAsync();
  
  for (const death of unrecoveredDeaths) {
    // Recreate gravestones/ground items from death.items
    if (death.zoneType === 'safe_area') {
      await this.spawnGravestoneForRecovery(death);
    } else {
      await this.spawnGroundItemsForRecovery(death);
    }
    
    // Mark as recovered to prevent duplicate processing
    await this.databaseSystem.markDeathRecoveredAsync(death.playerId);
  }
}
Crash recovery ensures that if the server crashes during death processing, items are never lost. The system recreates gravestones/ground items from the database on restart.

Death Lock System

To prevent item duplication on server restart/crash, deaths are tracked with dual persistence (memory + database):
interface DeathLockData {
  playerId: string;
  gravestoneId: string | null;     // If gravestone spawned
  groundItemIds: string[];         // If items on ground
  position: { x: number; y: number; z: number };
  timestamp: number;
  zoneType: string;
  itemCount: number;               // Total items dropped
  // Crash recovery fields
  items?: Array<{ itemId: string; quantity: number }>;
  killedBy?: string;
  recovered?: boolean;
}

Creating a Death Lock (Atomic Acquisition)

The system uses atomic acquisition to prevent race conditions:
async createDeathLock(
  playerId: string,
  options: {
    gravestoneId: string | null;
    groundItemIds: string[];
    position: { x: number; y: number; z: number };
    zoneType: string;
    itemCount: number;
    items?: Array<{ itemId: string; quantity: number }>;
    killedBy?: string;
  },
  tx?: TransactionContext,
): Promise<boolean> {
  // Server authority check
  if (!this.world.isServer) {
    console.error(`Client attempted death lock creation - BLOCKED`);
    return false;
  }
  
  // Fast path - check memory first
  if (this.activeDeaths.has(playerId)) {
    console.warn(`Death lock already exists for ${playerId} - rejecting duplicate`);
    return false;
  }
  
  const deathLockData = {
    playerId,
    gravestoneId: options.gravestoneId,
    groundItemIds: options.groundItemIds || [],
    position: options.position,
    timestamp: Date.now(),
    zoneType: options.zoneType,
    itemCount: options.itemCount,
    items: options.items || [],
    killedBy: options.killedBy || "unknown",
    recovered: false,
  };
  
  // ATOMIC acquisition using INSERT ... ON CONFLICT DO NOTHING
  if (this.databaseSystem) {
    const acquired = await this.databaseSystem.acquireDeathLockAsync(
      deathLockData,
      tx
    );
    
    if (!acquired) {
      // Another request already created a death lock
      console.warn(`Death lock already exists in database for ${playerId}`);
      return false;
    }
  }
  
  // Update memory AFTER successful database write
  this.activeDeaths.set(playerId, deathLockData);
  
  return true;
}
Atomic acquisition prevents duplicate death processing when multiple requests arrive simultaneously (e.g., client retry + server timeout).

Crash Recovery

When the server restarts, the DeathStateManager automatically recovers unfinished deaths:
// Called during system start()
async recoverUnfinishedDeaths(): Promise<void> {
  const unrecoveredDeaths = await this.databaseSystem.getUnrecoveredDeathsAsync();
  
  for (const death of unrecoveredDeaths) {
    // Check if gravestone/ground items still exist
    const gravestoneExists = !!this.world.entities?.get(death.gravestoneId);
    const existingGroundItems = death.groundItemIds.filter(id => 
      !!this.world.entities?.get(id)
    );
    
    if (gravestoneExists || existingGroundItems.length > 0) {
      // Entities exist - restore to memory cache
      this.activeDeaths.set(death.playerId, death);
    } else if (death.items && death.items.length > 0) {
      // Items stored but entities don't exist - recreate them
      const inventoryItems = death.items.map((item, index) => ({
        id: `recovery_${death.playerId}_${Date.now()}_${index}`,
        itemId: item.itemId,
        quantity: item.quantity,
        slot: -1,
        metadata: null,
      }));
      
      // Emit DEATH_RECOVERED event to recreate gravestone/ground items
      this.world.emit(EventType.DEATH_RECOVERED, {
        playerId: death.playerId,
        position: death.position,
        items: inventoryItems,
        killedBy: death.killedBy,
        zoneType: death.zoneType,
      });
    }
    
    // Mark as recovered in database
    await this.databaseSystem.markDeathRecoveredAsync(death.playerId);
  }
}
Recovery Scenarios:
  1. Entities exist: Restore death lock to memory (items already in world)
  2. Entities missing: Recreate gravestone/ground items from stored item data
  3. No items: Mark as recovered and clean up
Death locks persist until items are fully looted. This prevents item duplication if the server crashes before cleanup completes.

Death Flow

Safe Zone Death (OSRS-Accurate)

┌─────────────────────────────────────────────────────────────┐
│                  SAFE AREA DEATH FLOW                        │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [Player HP = 0]                                             │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────────┐                                       │
│  │ Atomic Death Lock│ ──► Database transaction              │
│  │ Acquisition      │     (prevents duplication)            │
│  └────────┬─────────┘                                       │
│           │ SUCCESS                                          │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Clear Inventory  │ ──► Items stored in death lock        │
│  │ & Equipment      │     (crash-safe)                      │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Play Death Anim  │ ──► 7 ticks (4.2s)                   │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Respawn Player   │ ──► Central Haven (0, 0, 0)          │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Spawn Gravestone │ ──► AFTER respawn (OSRS-style)       │
│  │ at Death Location│     1500 ticks (15 min) timer         │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Gravestone       │ ──► Items protected                   │
│  │ Expires          │     Only owner can loot               │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Items → Ground   │ ──► 6000 ticks (60 min) timer        │
│  │ Items            │     Anyone can loot                   │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Items Despawn    │ ──► Death lock cleared                │
│  └──────────────────┘                                       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Wilderness Death

┌─────────────────────────────────────────────────────────────┐
│                  WILDERNESS DEATH FLOW                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  [Player HP = 0]                                             │
│         │                                                    │
│         ▼                                                    │
│  ┌──────────────────┐                                       │
│  │ Atomic Death Lock│ ──► Database transaction              │
│  │ Acquisition      │                                       │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Clear Inventory  │ ──► Items stored in death lock        │
│  │ & Equipment      │                                       │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Spawn Ground     │ ──► Immediate drop (no gravestone)    │
│  │ Items            │     6000 ticks (60 min) timer         │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Loot Protection  │ ──► Killer has 100 ticks (1 min)     │
│  │ for Killer       │     exclusive access                  │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Play Death Anim  │ ──► 7 ticks (4.2s)                   │
│  └────────┬─────────┘                                       │
│           │                                                  │
│           ▼                                                  │
│  ┌──────────────────┐                                       │
│  │ Respawn Player   │ ──► Central Haven                     │
│  └──────────────────┘                                       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Gravestone System

Gravestones protect items in safe areas with OSRS-accurate timing.

Gravestone Timing (Updated in PR #566)

// From CombatConstants.ts
GRAVESTONE_TICKS: 1500,              // 15 minutes (was 1500)
GROUND_ITEM_DESPAWN_TICKS: 6000,     // 60 minutes (was 300 = 3 min)
UNTRADEABLE_DESPAWN_TICKS: 6000,     // 60 minutes (was 300 = 3 min)
LOOT_PROTECTION_TICKS: 100,          // 1 minute killer protection
Ground item despawn time was increased from 3 minutes to 60 minutes to match OSRS mechanics. This gives players ample time to retrieve items after gravestone expiration.

Gravestone Entity

const gravestone = new HeadstoneEntity(world, {
  id: generateEntityId(),
  name: `${playerName}'s gravestone`,
  position: deathPosition,
  headstoneData: {
    playerId,
    playerName,
    deathTime: Date.now(),
    deathMessage: `Here lies ${playerName}`,
    position: deathPosition,
    items: droppedItems,
    itemCount: droppedItems.length,
    despawnTime: Date.now() + (1500 * 600),  // 15 minutes
  },
});

Gravestone Expiration

When gravestone expires:
  1. Items transition to ground items
  2. Ground items have 60-minute despawn timer
  3. Death lock updated with ground item IDs
  4. Items become lootable by anyone
async onGravestoneExpired(playerId: string, groundItemIds: string[]): Promise<void> {
  const deathData = this.activeDeaths.get(playerId);
  if (!deathData) return;
  
  // Update tracking: gravestone → ground items
  deathData.gravestoneId = null;
  deathData.groundItemIds = groundItemIds;
  this.activeDeaths.set(playerId, deathData);
  
  // Update database
  await this.databaseSystem.updateGroundItemsAsync(playerId, groundItemIds);
}

Item Recovery

Looting from Gravestone

The loot system uses shadow state with transaction tracking for optimistic UI updates with automatic rollback on failure.
// From HeadstoneEntity.ts
private async processLootRequest(data: {
  playerId: string;
  itemId: string;
  quantity: number;
  transactionId: string;
}): Promise<void> {
  // Step 1: Check loot protection
  if (!this.canPlayerLoot(playerId)) {
    this.emitLootResult(playerId, transactionId, false, "PROTECTED");
    return;
  }
  
  // Step 2: Find item in gravestone
  const itemIndex = this.lootItems.findIndex(i => i.itemId === itemId);
  if (itemIndex === -1) {
    this.emitLootResult(playerId, transactionId, false, "ITEM_NOT_FOUND");
    return;
  }
  
  // Step 3: Check inventory space BEFORE removing
  const hasSpace = this.checkInventorySpace(playerId, itemId, quantity);
  if (!hasSpace) {
    this.emitLootResult(playerId, transactionId, false, "INVENTORY_FULL");
    return;
  }
  
  // Step 4: Block looting during death animation
  if (this.isPlayerInDeathState(playerId)) {
    this.emitLootResult(playerId, transactionId, false, "PLAYER_DYING");
    return;
  }
  
  // Step 5: Atomic remove from gravestone
  const removed = this.removeItem(itemId, quantity);
  if (!removed) {
    this.emitLootResult(playerId, transactionId, false, "ITEM_NOT_FOUND");
    return;
  }
  
  // Step 6: Double-check inventory space (closes race window)
  const stillHasSpace = this.checkInventorySpace(playerId, itemId, quantity);
  if (!stillHasSpace) {
    // Rollback: put item back
    this.lootItems.push({ id, itemId, quantity, slot, metadata });
    this.emitLootResult(playerId, transactionId, false, "INVENTORY_FULL");
    return;
  }
  
  // Step 7: Add to player inventory
  this.world.emit(EventType.INVENTORY_ITEM_ADDED, {
    playerId,
    item: { id: `loot_${playerId}_${Date.now()}`, itemId, quantity, slot: -1 },
  });
  
  // Step 8: Confirm success to client
  this.emitLootResult(playerId, transactionId, true);
}

Shadow State Pattern (Client)

The client uses optimistic updates with automatic rollback:
// From LootWindow.tsx
const handleLootClick = (itemId, quantity) => {
  const txnId = generateTransactionId();
  
  // 1. Optimistically remove from UI
  setItems(prev => prev.filter(i => i.itemId !== itemId));
  
  // 2. Track for rollback
  setPendingTransactions(prev => new Map(prev).set(txnId, {
    originalItem: item,
    originalIndex: index,
  }));
  
  // 3. Auto-rollback after 3 seconds if no response
  setTimeout(() => rollbackTransaction(txnId), 3000);
  
  // 4. Send request
  world.network.send("entityEvent", {
    event: EventType.CORPSE_LOOT_REQUEST,
    payload: { corpseId, itemId, quantity, transactionId: txnId },
  });
};

// Server confirms/rejects
const handleLootResult = (result: LootResult) => {
  if (result.success) {
    confirmTransaction(result.transactionId);
  } else {
    rollbackTransaction(result.transactionId);  // Put item back
  }
};
The shadow state pattern ensures the client UI always stays in sync with the server, even if loot requests fail or time out.

Loot All

Players can loot all items at once with a single request:
// Client sends batch request
world.network.send("entityEvent", {
  event: EventType.CORPSE_LOOT_ALL_REQUEST,
  payload: { corpseId, playerId, transactionId },
});

// Server processes atomically
private async processLootAllRequest(data: {
  playerId: string;
  transactionId: string;
}): Promise<void> {
  // Process each item with inventory space checking
  for (const item of this.lootItems) {
    const hasSpace = this.checkInventorySpace(playerId, item.itemId, item.quantity);
    if (!hasSpace) break;  // Stop if inventory full
    
    const removed = this.removeItem(item.itemId, item.quantity);
    if (removed) {
      this.world.emit(EventType.INVENTORY_ITEM_ADDED, { playerId, item });
      successfullyLooted.push(item);
    }
  }
  
  this.emitLootResult(playerId, transactionId, true, undefined, undefined, successfullyLooted.length);
}

Item Looting Tracking

When items are looted from gravestones or ground, the death lock is updated:
async onItemLooted(
  playerId: string,
  itemId: string,
  quantity: number = 1,
): Promise<void> {
  const deathData = this.activeDeaths.get(playerId);
  if (!deathData) return;
  
  // Remove from ground item list
  if (deathData.groundItemIds) {
    const index = deathData.groundItemIds.indexOf(itemId);
    if (index !== -1) {
      deathData.groundItemIds.splice(index, 1);
    }
  }
  
  // Update items array (crash recovery tracking)
  if (deathData.items) {
    const itemIndex = deathData.items.findIndex(i => i.itemId === itemId);
    if (itemIndex !== -1) {
      const item = deathData.items[itemIndex];
      if (item.quantity <= quantity) {
        // Remove entire item and decrement count
        deathData.items.splice(itemIndex, 1);
        deathData.itemCount = Math.max(0, deathData.itemCount - 1);
      } else {
        // Reduce quantity but keep item (don't decrement itemCount)
        item.quantity -= quantity;
      }
    }
  }
  
  // If all items looted, clear death lock
  if (deathData.itemCount === 0) {
    await this.clearDeathLock(playerId);
  } else {
    // Update database with new state
    await this.databaseSystem.saveDeathLockAsync(deathData);
  }
}

Clearing Death Lock

Death locks persist until all items are looted or despawn:
async clearDeathLock(playerId: string): Promise<void> {
  // Remove from memory
  this.activeDeaths.delete(playerId);

  // Remove from database
  if (this.databaseSystem) {
    await this.databaseSystem.deleteDeathLockAsync(playerId);
  }
}

// Death lock is cleared when:
// 1. All items are looted from gravestone (CORPSE_EMPTY event)
// 2. Ground items despawn (timeout)
// 3. Gravestone expires and ground items despawn
Death Lock Persistence: The death lock is NOT cleared on respawn. It persists until all items are recovered or despawn. This enables crash recovery.

Reconnect Validation

When a player reconnects, the system checks for active deaths:
async hasActiveDeathLock(playerId: string): Promise<boolean> {
  // Check memory cache first (fast path)
  if (this.activeDeaths.has(playerId)) return true;

  // Fallback to database (critical for crash recovery)
  if (this.databaseSystem) {
    const dbData = await this.databaseSystem.getDeathLockAsync(playerId);
    if (dbData) {
      // Restore to memory cache INCLUDING crash recovery fields
      const deathLock: DeathLock = {
        playerId: dbData.playerId,
        gravestoneId: dbData.gravestoneId,
        groundItemIds: dbData.groundItemIds,
        position: dbData.position,
        timestamp: dbData.timestamp,
        zoneType: dbData.zoneType,
        itemCount: dbData.itemCount,
        items: dbData.items,
        killedBy: dbData.killedBy,
        recovered: dbData.recovered,
      };
      this.activeDeaths.set(playerId, deathLock);
      return true;
    }
  }

  return false;
}
This prevents:
  • Item duplication if server crashes mid-death
  • Double death processing on reconnect
  • Gravestone re-creation exploits
  • Item loss when player disconnects during death

Player Disconnect Handling

When a player disconnects mid-death, the death lock is preserved:
private async handlePlayerDisconnect(playerId: string): Promise<void> {
  const deathData = this.activeDeaths.get(playerId);
  if (!deathData) return;
  
  // Clear from memory but keep in database for reconnect validation
  this.activeDeaths.delete(playerId);
  
  console.log(
    `Cleared death lock from memory for disconnected player ${playerId}` +
    ` (preserved in database for reconnect)`
  );
}
Why This Matters:
  • Gravestone/ground items persist for other players to see
  • Player can recover items when they reconnect
  • Memory is freed for disconnected players
  • Database ensures no item duplication on reconnect

Respawn Validation

The respawn system validates that players are actually dead before allowing respawn:
// From ServerNetwork/index.ts
this.handlers["onRequestRespawn"] = (socket, _data) => {
  const playerEntity = socket.player;
  if (!playerEntity) return;

  // Validate player is actually dead before allowing respawn
  const healthComponent = playerEntity.data?.properties?.healthComponent;
  const isDead = healthComponent?.isDead === true;

  if (!isDead) {
    console.warn(
      `[ServerNetwork] Rejected respawn request from ${playerEntity.id} - player is not dead`,
    );
    return;
  }

  // Process respawn
  world.emit(EventType.PLAYER_RESPAWN_REQUEST, { playerId: playerEntity.id });
};

Death Screen UI

The death screen includes:
  • Respawn button with spam prevention
  • Countdown timer showing time until items despawn
  • Timeout handling (10 second timeout with retry)
  • Input blocking during death animation
// From CoreUI.tsx
const [isRespawning, setIsRespawning] = useState(false);
const [respawnTimedOut, setRespawnTimedOut] = useState(false);
const [countdown, setCountdown] = useState<number>(
  Math.max(0, Math.floor((data.respawnTime - Date.now()) / 1000)),
);

// Timeout handler - re-enable button if server doesn't respond
const RESPAWN_TIMEOUT_MS = 10000;

useEffect(() => {
  if (!isRespawning) return;

  const timeoutId = setTimeout(() => {
    console.warn("[DeathScreen] Respawn request timed out after 10 seconds");
    setIsRespawning(false);
    setRespawnTimedOut(true);
  }, RESPAWN_TIMEOUT_MS);

  return () => clearTimeout(timeoutId);
}, [isRespawning]);

Movement Blocking

Players cannot move while dying or dead:
// From TileMovementManager.ts
handleMoveRequest(socket: ServerSocket, data: unknown): void {
  const playerEntity = socket.player;
  if (!playerEntity) return;

  // CRITICAL: Block movement during death state
  const entityData = playerEntity.data as { deathState?: DeathState };
  if (
    entityData?.deathState === DeathState.DYING ||
    entityData?.deathState === DeathState.DEAD
  ) {
    return;  // Silently reject - player is dead
  }

  // ... process movement
}
Death States: The system uses DeathState.DYING (during animation) and DeathState.DEAD (after animation) to block actions during the death sequence.

Combat State Cleanup

When a player dies or respawns, all combat-related states are cleaned up across multiple systems:

Death Event Handlers

// From AggroSystem.ts
world.on(EventType.PLAYER_SET_DEAD, (data) => {
  if (data.isDead) {
    // Stop all mobs from chasing this player
    for (const [mobId, mobState] of this.mobStates) {
      if (mobState.currentTarget === playerId) {
        mobState.isChasing = false;
        mobState.currentTarget = null;
        mobState.isInCombat = false;
      }
      mobState.aggroTargets.delete(playerId);
    }
  }
});

// From CombatSystem.ts
world.on(EventType.PLAYER_RESPAWNED, (data) => {
  // Clear all combat states targeting this player
  const clearedAttackers = combatStateService.clearStatesTargeting(playerId);

  // Clear player's own combat state
  combatStateService.clearState(playerId);
});

Multi-Layer Cleanup

The system uses defense-in-depth with three layers of cleanup:
  1. Event-based cleanup: Death/respawn events trigger immediate cleanup
  2. Runtime guards: Health checks in aggro/combat loops
  3. Timeout cleanup: Stale states cleaned up after timeout
// Layer 3: Runtime guard in aggro loop
const player = this.world.getPlayer(mobState.currentTarget);

// Check if player is dead - stop chasing immediately
const playerHealth = player.health;
if (playerHealth?.current !== undefined && playerHealth.current <= 0) {
  this.stopChasing(mobState);
  mobState.currentTarget = null;
  mobState.aggroTargets.delete(player.id);
  return;
}

Death Events

EventDataDescription
ENTITY_DEATHentityId, killerId, positionEntity died
PLAYER_DEATHplayerId, killerId, position, zoneTypePlayer death
PLAYER_SET_DEADplayerId, isDeadPlayer death state changed
PLAYER_RESPAWNEDplayerId, spawnPositionPlayer respawned
GRAVESTONE_SPAWNEDgravestoneId, playerId, positionGravestone created
GRAVESTONE_EXPIREDgravestoneId, playerId, groundItemIdsGravestone timed out
DEATH_RECOVEREDplayerId, position, items, killedBy, zoneTypeDeath recovered after crash
PLAYER_UNREGISTEREDidPlayer disconnected (triggers death lock cleanup)

Death Constants

// From CombatConstants.ts (updated in PR #566)
DEATH: {
  ANIMATION_TICKS: 7,                    // 4.2 seconds (was 8)
  COOLDOWN_TICKS: 17,                    // 10.2 seconds between deaths
  RECONNECT_RESPAWN_DELAY_TICKS: 1,      // Instant respawn on reconnect
  STALE_LOCK_AGE_TICKS: 3000,            // 30 minutes (was 6000 = 1 hour)
  DEFAULT_RESPAWN_POSITION: { x: 0, y: 0, z: 0 },
  DEFAULT_RESPAWN_TOWN: "Central Haven",
},

GRAVESTONE_TICKS: 1500,                  // 15 minutes
GROUND_ITEM_DESPAWN_TICKS: 6000,         // 60 minutes (was 300 = 3 min)
UNTRADEABLE_DESPAWN_TICKS: 6000,         // 60 minutes (was 300 = 3 min)
LOOT_PROTECTION_TICKS: 100,              // 1 minute killer protection
Ground item despawn time was increased from 3 minutes to 60 minutes in PR #566 to match OSRS mechanics.

Security Features

Rate Limiting

// Loot requests are rate-limited per player
private lootRateLimiter = new Map<string, number>();
private readonly LOOT_RATE_LIMIT_MS = 100;

Atomic Operations

All loot operations are queued to prevent concurrent access:
// From HeadstoneEntity.ts
private lootQueue: Promise<void> = Promise.resolve();

private handleLootRequest(data): void {
  // Queue operation to ensure atomicity
  this.lootQueue = this.lootQueue
    .then(() => this.processLootRequest(data))
    .catch(error => {
      console.error(`[HeadstoneEntity] Loot request failed:`, error);
      this.emitLootResult(data.playerId, data.transactionId, false, "INVALID_REQUEST");
    });
}

Death State Blocking

Players cannot loot while dying or dead:
private isPlayerInDeathState(playerId: string): boolean {
  const playerEntity = this.world.entities.get(playerId);
  if (playerEntity && "data" in playerEntity) {
    const data = playerEntity.data as { deathState?: DeathState };
    return data?.deathState === DeathState.DYING || 
           data?.deathState === DeathState.DEAD;
  }
  return false;
}
OSRS Accuracy: Ground item despawn time was updated from 3 minutes to 60 minutes to match OSRS behavior.

Database Schema

Death locks are stored in the player_deaths table:
CREATE TABLE player_deaths (
  player_id TEXT PRIMARY KEY,
  gravestone_id TEXT,
  ground_item_ids TEXT[],           -- Array of entity IDs
  position_x REAL NOT NULL,
  position_y REAL NOT NULL,
  position_z REAL NOT NULL,
  timestamp BIGINT NOT NULL,
  zone_type TEXT NOT NULL,
  item_count INTEGER NOT NULL,
  items JSONB,                      -- Crash recovery: [{itemId, quantity}]
  killed_by TEXT,                   -- Crash recovery: killer name
  recovered BOOLEAN DEFAULT FALSE,  -- Crash recovery: has been processed
  created_at BIGINT NOT NULL
);
Key Fields:
  • items: Stores item data for crash recovery (recreate gravestone if entities lost)
  • killed_by: Displays on gravestone (“Here lies X, killed by Y”)
  • recovered: Prevents duplicate recovery on multiple restarts

Gravestone Display

Gravestones now show the player’s name instead of their ID:
// From HeadstoneEntity.ts
const playerName = this.getPlayerName(ownerId);
const killedByText = killedBy ? ` killed by ${killedBy}` : "";

contextMenu.addOption({
  id: "loot",
  label: `Loot ${playerName}'s gravestone${killedByText}`,
  enabled: true,
  priority: 1,
});
Name Resolution:
  • Queries characters table for player’s username
  • Falls back to player ID if name not found
  • Sanitizes names with Unicode normalization (security)


Duel Arena Deaths

Deaths in the Duel Arena are handled differently:

No Item Loss

// From PlayerDeathSystem.ts
const duelSystem = this.world.getSystem("duel");
if (duelSystem?.isPlayerInActiveDuel(playerId)) {
  // Duel death - DuelSystem handles resolution
  // - No gravestone spawned
  // - No items lost
  // - Winner receives staked items only
  // - Both players restored to full health
  // - Both teleported to duel arena lobby
  return;
}

// Normal death - proceed with gravestone/item drop
this.handleNormalDeath(playerId);

Death State Handling

The DuelSystem sets the death state but prevents normal death processing:
// From DuelSystem.handlePlayerDeath()
if (session.state !== "FIGHTING") {
  // Ignore deaths outside active combat
  return;
}

// Set state to FINISHED immediately to prevent double-processing
session.state = "FINISHED";

// Delay resolution for death animation (8 ticks = 4.8 seconds)
setTimeout(() => {
  this.resolveDuel(session, winnerId, loserId, "death");
}, ticksToMs(DEATH_RESOLUTION_DELAY_TICKS));

Health Restoration

After duel resolution, both players are restored to full health:
// From DuelCombatResolver.ts
private restorePlayerHealth(playerId: string): void {
  // Clear death state using PlayerEntity helper
  const playerEntity = this.world.entities.get(playerId);
  if (playerEntity instanceof PlayerEntity) {
    playerEntity.resetDeathState();
  }
  
  // Emit respawn event to trigger health restoration
  this.world.emit(EventType.PLAYER_RESPAWNED, {
    playerId,
    spawnPosition: LOBBY_SPAWN_CENTER,
    townName: "Duel Arena",
  });
  
  // Clear death state on client
  this.world.emit(EventType.PLAYER_SET_DEAD, {
    playerId,
    isDead: false,
  });
}
Duel deaths use the same death animation (8 ticks) as normal deaths for visual consistency.