Skip to main content

Death & Respawn System

The death system handles player deaths with zone-aware mechanics, gravestone spawning, and secure item recovery. Completely rewritten in PR #1094 (March 2026) to fix SQLite deadlock, equipment duplication, and implement OSRS-style “keep 3 most valuable items” for safe zone deaths.
Death code lives in packages/shared/src/systems/shared/ with:
  • combat/PlayerDeathSystem.ts - Main death orchestrator
  • combat/DeathUtils.ts - Pure utility functions (sanitization, keep-3, validation)
  • combat/DeathTypes.ts - Type definitions
  • death/DeathStateManager.ts - Death lock persistence and crash recovery
  • death/SafeAreaDeathHandler.ts - Safe zone death logic (gravestone system)
  • death/WildernessDeathHandler.ts - Wilderness death logic (immediate ground drop)
  • death/ZoneDetectionSystem.ts - Zone type detection
Breaking Change (March 2026): PLAYER_DIED event is deprecated. Use PLAYER_SET_DEAD for client death UI, or ENTITY_DEATH with type filter for server-side death processing.

Death Zones

type ZoneType = 
  | "SAFE_AREA"   // Town, bank areas - keep 3 most valuable items (OSRS-style)
  | "WILDERNESS"; // PvP wilderness - all items drop on death
OSRS-Accurate Mechanics:
  • Safe Zone: Keep 3 most valuable items (by manifest value), rest goes to gravestone
  • Wilderness: All items drop immediately to ground (no gravestone)
  • Duel Arena: No item drops (inventory/equipment preserved)

Death Lock System

To prevent item duplication on server restart/crash, deaths are tracked with database persistence. Updated in PR #1094 to include kept items for crash recovery.
interface DeathLock {
  playerId: string;
  gravestoneId?: string;           // If gravestone spawned
  position: { x: number; y: number; z: number };
  timestamp: number;
  zoneType: ZoneType;
  itemCount: number;               // Total items dropped
  items: Array<{                   // Dropped items (for gravestone)
    itemId: string;
    quantity: number;
  }>;
  keptItems: Array<{               // Kept items (for respawn) - NEW in PR #1094
    itemId: string;
    quantity: number;
  }>;
  killedBy: string;                // Killer name (sanitized)
}
New in PR #1094:
  • items field stores dropped items for gravestone recovery
  • keptItems field stores kept items for respawn recovery
  • killedBy field stores sanitized killer name (XSS/injection protected)

Creating a Death Lock

async createDeathLock(
  playerId: string,
  options: {
    gravestoneId?: string;
    groundItemIds?: string[];
    position: { x: number; y: number; z: number };
    zoneType: ZoneType;
    itemCount: number;
  },
  tx?: TransactionContext,  // Optional DB transaction
): Promise<void> {
  // Server authority check
  if (!this.world.isServer) {
    console.error(`[DeathStateManager] Client attempted death lock creation - BLOCKED`);
    return;
  }
  
  const deathData: DeathLock = {
    playerId,
    gravestoneId: options.gravestoneId,
    groundItemIds: options.groundItemIds,
    position: options.position,
    timestamp: Date.now(),
    zoneType: options.zoneType,
    itemCount: options.itemCount,
  };
  
  // Store in memory cache
  this.activeDeaths.set(playerId, deathData);
  
  // Persist to database (prevents duplication on restart)
  if (this.databaseSystem) {
    await this.databaseSystem.saveDeathLockAsync(deathData, tx);
  }
}

Death Flow (Updated March 2026)

Safe Zone Death (OSRS Keep-3)

  1. Player HP reaches 0
  2. Transaction starts:
    • Clear equipment in-memory (skip DB persist)
    • Clear inventory in-memory (skip DB persist)
    • Split items: keep 3 most valuable, drop rest
    • Create death lock with kept items
    • Commit transaction
  3. After transaction:
    • Persist equipment clear to DB (retry on failure)
    • Persist inventory clear to DB (retry on failure)
    • Emit PLAYER_SET_DEAD event
  4. Respawn (tick-based, deterministic):
    • Return kept items to inventory
    • Spawn gravestone with dropped items (5 minute timer)
    • Teleport to spawn town
    • Clear death lock
  5. Player can return to gravestone to reclaim dropped items
Two-Phase Persist Pattern: In-memory clear inside transaction, DB persist after transaction. Prevents SQLite deadlock from nested transactions.

Wilderness Death

  1. Player HP reaches 0
  2. Transaction starts:
    • Clear equipment in-memory
    • Clear inventory in-memory
    • All items marked for ground drop (no keep-3)
    • Create death lock
    • Commit transaction
  3. After transaction:
    • Persist clears to DB
    • Drop all items to ground immediately (no gravestone)
    • Items despawn after 2 minutes
  4. Respawn:
    • Teleport to spawn town
    • No items returned
    • Clear death lock after ground items despawn

Duel Arena Death

  1. Player HP reaches 0 in duel arena
  2. No item drops (inventory/equipment preserved)
  3. Death animation plays
  4. DuelSystem handles respawn and stakes
  5. No death lock created

Gravestone System

// Gravestone entity config
const gravestone = new GravestoneEntity(this.world, {
  position: deathPosition,
  ownerId: playerId,
  items: droppedItems,
  expiresAt: Date.now() + 15 * 60 * 1000,  // 15 minutes
});

// From CombatConstants.ts
GRAVESTONE_TICKS: 1500,          // 15 minutes in ticks (1500 × 0.6s)
GRAVESTONE_WARNING_TICKS: 500,   // 5 minute warning

Gravestone Expiration

When gravestone expires:
  1. Items transition to ground items
  2. Ground items have additional despawn timer
  3. onGravestoneExpired() updates death lock
async onGravestoneExpired(playerId: string, groundItemIds: string[]): Promise<void> {
  const deathData = this.activeDeaths.get(playerId);
  if (!deathData) return;
  
  // Update tracking: gravestone → ground items
  deathData.gravestoneId = undefined;
  deathData.groundItemIds = groundItemIds;
  this.activeDeaths.set(playerId, deathData);
  
  // Update database
  await this.databaseSystem.updateGroundItemsAsync(playerId, groundItemIds);
}

Item Recovery

Looting from Gravestone

// Player interacts with their gravestone
async lootGravestone(playerId: string, gravestoneId: string): Promise<void> {
  const gravestone = this.world.entities.get(gravestoneId);
  
  // Verify ownership
  if (gravestone.ownerId !== playerId) {
    this.emitMessage(playerId, "This isn't your gravestone.", "error");
    return;
  }
  
  // Transfer items to inventory
  for (const item of gravestone.items) {
    const success = await this.inventorySystem.addItem(
      playerId,
      item.itemId,
      item.quantity
    );
    
    if (!success) {
      // Inventory full - leave remaining items
      break;
    }
    
    // Track item looted
    await this.deathStateManager.onItemLooted(playerId, item.id);
  }
  
  // Destroy gravestone if empty
  if (gravestone.items.length === 0) {
    this.entityManager.destroyEntity(gravestoneId);
    await this.deathStateManager.clearDeathLock(playerId);
  }
}

Clearing Death Lock

async clearDeathLock(playerId: string): Promise<void> {
  // Remove from memory
  this.activeDeaths.delete(playerId);
  
  // Remove from database
  if (this.databaseSystem) {
    await this.databaseSystem.deleteDeathLockAsync(playerId);
  }
}

Reconnect Validation

When a player reconnects, the system checks for active deaths:
async hasActiveDeathLock(playerId: string): Promise<boolean> {
  // Check memory cache first
  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
      this.activeDeaths.set(playerId, dbData);
      return true;
    }
  }
  
  return false;
}
This prevents:
  • Item duplication if server crashes mid-death
  • Double death processing on reconnect
  • Gravestone re-creation exploits

Death Events

EventDataDescription
ENTITY_DEATHentityId, entityType, killedBy, deathPositionEntity died (use this for death processing)
PLAYER_SET_DEADplayerId, isDead, deathPosition?Player death state changed (use this for client UI)
PLAYER_DIEDplayerIdDEPRECATED - Use PLAYER_SET_DEAD or ENTITY_DEATH instead
PLAYER_RESPAWNEDplayerId, spawnPosition, townName, deathLocation?Player respawned
DEATH_RECOVEREDplayerId, position, items, killedBy, zoneTypeCrash recovery (server restart)
CORPSE_EMPTYcorpseId, playerIdAll items looted from gravestone
DEATH_HEADSTONE_EXPIREDheadstoneId, playerIdGravestone timed out
AUDIT_LOGaction, playerId, success, failureReason?Ops-visible audit events
Event Migration (PR #1094):
// ❌ OLD (deprecated)
world.on(EventType.PLAYER_DIED, (data: { playerId: string }) => {
  // Handle player death
});

// ✅ NEW (use ENTITY_DEATH with type filter)
world.on(EventType.ENTITY_DEATH, (data: { 
  entityId: string; 
  entityType: string;
  killedBy?: string;
  deathPosition?: { x: number; y: number; z: number };
}) => {
  if (data.entityType === 'player') {
    // Handle player death
  }
});

// ✅ NEW (use PLAYER_SET_DEAD for client UI)
world.on(EventType.PLAYER_SET_DEAD, (data: {
  playerId: string;
  isDead: boolean;
  deathPosition?: [number, number, number];
}) => {
  // Update death screen UI
});

Death Constants

// From CombatConstants.ts
export const COMBAT_CONSTANTS = {
  DEATH: {
    ANIMATION_TICKS: 5,              // Death animation duration (3 seconds)
    COOLDOWN_TICKS: 1,               // Death cooldown (600ms)
    RECONNECT_RESPAWN_DELAY_TICKS: 2, // Delay before auto-respawn on reconnect
    STALE_LOCK_AGE_TICKS: 600000,    // 10 minutes (stale lock cleanup)
    DEFAULT_RESPAWN_POSITION: { x: 0, y: 10, z: 0 },
    DEFAULT_RESPAWN_TOWN: "Central Haven",
  },
  GRAVESTONE_TICKS: 500,             // 5 minutes to reclaim (reduced from 15 minutes)
  GROUND_ITEM_TICKS: 200,            // 2 minutes on ground
};

// From DeathUtils.ts
export const ITEMS_KEPT_ON_DEATH = 3;  // OSRS keep-3 system

export const POSITION_VALIDATION = {
  WORLD_BOUNDS: 10000,  // Max 10km from origin
  MAX_HEIGHT: 500,      // Max height
  MIN_HEIGHT: -50,      // Allow some underground (caves)
} as const;

OSRS Keep-3 System (New in PR #1094)

Safe zone deaths keep the 3 most valuable items (by manifest value):
import { splitItemsForSafeDeath, ITEMS_KEPT_ON_DEATH } from './DeathUtils';

// Get all items (inventory + equipment)
const allItems = [...inventoryItems, ...equipmentItems];

// Split into kept/dropped
const { kept, dropped } = splitItemsForSafeDeath(allItems, ITEMS_KEPT_ON_DEATH);

// Store kept items for respawn
this.itemsKeptOnDeath.set(playerId, kept);

// Create gravestone with dropped items
await this.spawnGravestone(playerId, position, dropped, killedBy);
Algorithm:
  1. Tag each item with its manifest value
  2. Sort descending by value (most valuable first)
  3. Greedily assign keep-count without expanding stacks
  4. Split into kept/dropped lists
Example:
const allItems = [
  { itemId: "dragon_scimitar", quantity: 1 },  // value: 60000
  { itemId: "rune_platebody", quantity: 1 },   // value: 40000
  { itemId: "shark", quantity: 10 },           // value: 800 each
  { itemId: "coins", quantity: 50000 },        // value: 1 each
];

const { kept, dropped } = splitItemsForSafeDeath(allItems, 3);

// kept = [dragon_scimitar, rune_platebody, 1 shark]
// dropped = [9 sharks, 50000 coins]
Reference: OSRS Wiki - Items Kept on Death

Two-Phase Persist Pattern (New in PR #1094)

Problem: Death transaction called clearEquipmentAndReturn() and clearInventoryImmediate() which each opened nested DB transactions, causing SQLite to deadlock. Solution: In-memory clear inside transaction, DB persist after transaction.
// Inside transaction
await databaseSystem.executeInTransaction(async (tx) => {
  // Clear in-memory, skip DB persist
  await equipmentSystem.clearEquipmentAndReturn(playerId, tx);
  await inventorySystem.clearInventoryImmediate(playerId, true); // skipPersist=true
  
  // Create death lock
  await deathStateManager.createDeathLock(playerId, { ... }, tx);
});

// After transaction (idempotent)
await equipmentSystem.clearEquipmentImmediate(playerId);
await inventorySystem.clearInventoryImmediate(playerId, false);
Crash Recovery: If server crashes between transaction commit and DB persist, death lock prevents reconnect inventory load. Items are not restored to player. Persist Retry Queue: Single-retry queue (bounded to 100 entries) handles transient DB failures. Emits AUDIT_LOG on retry failure.

Gravestone Privacy (New in PR #1094)

Gravestone loot items are hidden from network broadcast (OSRS-accurate):
// HeadstoneEntity.getNetworkData()
getNetworkData(): Record<string, unknown> {
  return {
    lootItemCount: this.lootItems.length, // Only count is broadcast
    // lootItems NOT included (privacy)
  };
}

// Full loot data sent only to interacting player
world.network.sendTo(playerId, 'corpseLoot', {
  corpseId: gravestoneId,
  items: this.lootItems, // Full item list (targeted packet)
});
Impact: Other players cannot see gravestone contents until interaction.

Security Features (New in PR #1094)

Duel Escape Prevention

// Block respawn during active duel
if (duelSystem?.isPlayerInActiveDuel?.(playerId)) {
  this.logger.warn("Blocked respawn request during active duel", { playerId });
  return;
}
Guards:
  • handleRespawnRequest() - Blocks manual respawn button
  • initiateRespawn() - Defense-in-depth guard

Position Validation

import { validatePosition, isPositionInBounds } from './DeathUtils';

// Validate and clamp position
const validPosition = validatePosition(deathPosition);
if (!validPosition) {
  this.logger.error("Invalid death position (NaN/Infinity)", { playerId });
  return;
}

// Log warning if clamped
if (!isPositionInBounds(deathPosition)) {
  this.logger.warn("Death position out of bounds, clamped", { playerId });
}

Killer Name Sanitization

import { sanitizeKilledBy } from './DeathUtils';

// Sanitize killer name (XSS/injection protection)
const killedBy = sanitizeKilledBy(killedByRaw);

// Store sanitized name
await deathStateManager.createDeathLock(playerId, {
  killedBy, // Safe for display
  // ...
});
Attack Vectors Prevented:
  • Homograph attacks (Cyrillic ‘а’ vs Latin ‘a’)
  • Zero-width characters (invisible manipulation)
  • BiDi overrides (text reversal)
  • XSS injection (script tags)
  • Buffer overflow (length capped at 64 chars)