Skip to main content

Player Death System Architecture

Last Updated: March 26, 2026
Related PR: #1094

Overview

The player death system implements OSRS-accurate death mechanics with robust transaction handling, crash recovery, and privacy-preserving gravestone loot. The system was completely rewritten in March 2026 to fix critical bugs including SQLite deadlock, equipment duplication, and missing keep-3 mechanics.

Core Features

1. OSRS-Style Keep-3 System

In safe zones (non-PvP areas), players keep their 3 most valuable items on death:
  • Items are ranked by manifest value field
  • Stacked items (quantity > 1) are handled efficiently without memory explosion
  • Kept items are returned to inventory on respawn
  • Dropped items go to gravestone for retrieval
Implementation: DeathUtils.splitItemsForSafeDeath()
  • O(n log n) complexity on unique item slots (not individual units)
  • Greedy quantity assignment for stacked items
  • Deterministic tiebreaking on original index when values are equal

2. Two-Phase Persist Pattern

Death processing uses a two-phase pattern to prevent database deadlock: Phase 1 - Inside Transaction:
  • Clear inventory and equipment in-memory
  • Create death lock with kept items
  • Skip database persist (would cause nested transaction deadlock)
Phase 2 - After Transaction:
  • Persist equipment clear to database
  • Persist inventory clear to database
  • Retry queue handles failures
Why This Matters: SQLite doesn’t support nested transactions. The old code called clearEquipmentAndReturn() and clearInventoryImmediate() inside executeInTransaction(), each opening their own transaction, causing silent deadlock.

3. Death Lock System

Death locks prevent state desync during the death-to-respawn window:
Purpose:
  • Prevent reconnect during death processing from corrupting state
  • Store kept items for crash recovery (in-memory map is lost on server crash)
  • Auto-expire stale locks (TTL: 5 minutes)
Crash Recovery: If server crashes between transaction commit and persist completion, death lock persists kept items. On reconnect, system checks for active death lock and recovers kept items from database.

4. Gravestone Privacy

Gravestone loot is privacy-preserving (OSRS-accurate):
  • lootItems array is stripped from network broadcast
  • Only lootItemCount (number) is synced to all clients
  • Full loot data sent only to interacting player via corpseLoot packet
  • Empty gravestone guard uses synced lootItemCount field
Why: In OSRS, gravestone contents are hidden until interaction. Broadcasting full loot arrays would leak player wealth to all nearby clients.

5. Persist Retry Queue

Post-transaction persist failures are retried once:
Behavior:
  • Single retry per failure (no infinite loops)
  • Retries drained once per tick in processPendingRespawns()
  • Track in-flight retries to prevent races with reconnect/new death
  • Bounded queue (max 100 entries) to prevent unbounded growth
  • AUDIT_LOG events on retry failure for ops visibility

Architecture

File Structure

Key Components

PlayerDeathSystem

Main orchestrator for death processing:

DeathUtils

Pure utility functions (stateless, side-effect-free):

DeathStateManager

Database operations for death locks:

Death Flow

Safe Zone Death (Keep-3)

Error Recovery

Transaction Failure:
  • Catch in handlePlayerDeath()
  • Reset player to alive state
  • Revive in-place (deathPosition)
  • Log error with stack trace
Persist Failure (after transaction):
  • Add to retry queue
  • Single retry on next tick
  • AUDIT_LOG event on retry failure
  • Bounded queue (max 100 entries)
Server Crash (during death window):
  • Death lock persists kept items to database
  • On reconnect: check for active death lock
  • Emit AUDIT_LOG event (ops visibility)
  • Recover kept items from death lock
  • Complete respawn flow

Event Migration

PLAYER_DIED → PLAYER_SET_DEAD

Old Event (deprecated):
New Event:
Why: PLAYER_DIED was emitted multiple times in the old flow (once in PlayerSystem.handleDeath, again in postDeathCleanup). PLAYER_SET_DEAD is emitted exactly once, after all death processing completes. Migration: Search codebase for PLAYER_DIED and replace with PLAYER_SET_DEAD. The event payload is compatible (both have playerId and optional killedBy). Deprecation Timeline: PLAYER_DIED is marked @deprecated in JSDoc. Will be removed in next major version.

Database Schema

death_locks Table

Indexes:
  • Primary key on player_id (one lock per player)
  • Index on expires_at for cleanup queries
TTL: Locks auto-expire after 5 minutes. Cleanup runs periodically via cleanupExpiredLocks().

Testing

Unit Tests

DeathUtils.test.ts (51 tests):
  • sanitizeKilledBy() - XSS, Unicode normalization, BiDi overrides, control characters
  • splitItemsForSafeDeath() - OSRS keep-3, stack handling, edge cases, OOM regression
  • validatePosition() - NaN/Infinity handling, clamping, bounds checking
  • isPositionInBounds() - Boundary validation
PlayerDeathFlow.test.ts (10 tests):
  • Duel guard blocks respawn during active duel
  • Death processing race guard prevents duplicate ENTITY_DEATH
  • Tick-based respawn timing
  • Persist retry queue drain
  • PLAYER_DIEDPLAYER_SET_DEAD migration
HeadstoneEntity.test.ts (5 tests):
  • modify() network sync logic
  • lootItemCount=0 clears local items
  • Non-zero preservation
  • Missing field handling
  • Null mesh safety

Integration Tests

PvPDeath.integration.test.ts:
  • Wilderness death (lose all items)
  • Safe zone death (keep 3 most valuable)
  • Gravestone creation and loot retrieval
  • Death lock cleanup

Security Considerations

Input Validation

killedBy Sanitization:
  • Normalize Unicode to NFKC (prevent homograph attacks)
  • Remove zero-width characters (U+200B-U+200D, U+FEFF)
  • Remove BiDi override characters (U+202A-U+202E)
  • Remove control characters (0x00-0x1F, 0x7F)
  • Remove dangerous HTML characters (<>'\"&)
  • Limit to 64 characters
  • Default to “unknown” for invalid inputs
Position Validation:
  • Check for NaN/Infinity
  • Clamp to world bounds (±10km from origin)
  • Clamp height (-50m to 500m)
  • Reject completely invalid positions
Gravestone ID Filtering:
  • Early return for gravestone_ prefix in handlePlayerDeath()
  • Prevents gravestone destruction from triggering false player death
  • Performance optimization (not security boundary - real gate is isServer check)

Server-Only Processing

Death processing is strictly server-only:
Client-side death attempts are logged for security visibility.

Duel Guard

Respawn is blocked during active duels:
Prevents exploits where players respawn mid-duel to escape combat.

Performance Characteristics

Memory

  • Kept Items Map: O(n) space where n = number of dead players awaiting respawn
  • Retry Queue: Bounded at 100 entries (prevents unbounded growth)
  • Death Locks: One per player, auto-expire after 5 minutes

CPU

  • splitItemsForSafeDeath(): O(n log n) on unique item slots
    • Old implementation: O(n × quantity) - expanded stacks into individual entries
    • New implementation: Operates on unique slots with greedy assignment
    • Example: 10,000 arrows = 1 slot, not 10,000 array entries
  • Gravestone Cleanup: O(1) per gravestone (event-driven via CORPSE_EMPTY)
    • Fallback: Tick-based expiration in SafeAreaDeathHandler (if event is lost)

Monitoring & Observability

Audit Events

The system emits AUDIT_LOG events for ops visibility:

Debug Logging

Key decision points are logged:

Grep Tags

Search logs for these tags:
  • DEATH_PERSIST_DESYNC - Persist failure after transaction commit
  • AUDIT_LOG - High-severity events requiring ops attention
  • [DEATH-DEBUG] - Removed in cleanup (all debug logs now use Logger system)

Common Issues & Solutions

Issue: Player stuck in death animation, never respawns

Symptoms:
  • Player plays death animation
  • Death screen appears
  • Respawn timer never triggers
  • Player stuck in DYING state
Diagnosis:
  1. Check server logs for DEATH_PERSIST_DESYNC tag
  2. Check for transaction errors in death processing
  3. Query death locks: SELECT * FROM death_locks WHERE player_id = ?
Root Cause (fixed in PR #1094):
  • Nested transaction deadlock in SQLite
  • clearEquipmentAndReturn() and clearInventoryImmediate() opened transactions inside executeInTransaction()
Recovery:
Prevention: Update to latest version (March 26, 2026+). Two-phase persist pattern eliminates nested transactions.

Issue: Equipment duplicates on death

Symptoms:
  • Player dies
  • Equipment appears in both gravestone and inventory after respawn
  • Item duplication exploit
Root Cause (fixed in PR #1094):
  • Equipment clear failed silently due to transaction deadlock
  • Gravestone created with equipment items
  • Player respawned with equipment still equipped
Prevention: Update to latest version. Two-phase persist ensures equipment is cleared before gravestone creation.

Issue: Gravestone shows stale items after looting

Symptoms:
  • Player loots gravestone
  • Gravestone entity persists with old items
  • Next death shows duplicate items in gravestone
Root Cause (fixed in commit 498cdff):
  1. removeItem() used setTimeout(500ms) for self-destruct (unreliable)
  2. getNetworkData() only sent lootItemCount, never actual items array
  3. Client entity had no items → empty loot window
Fix:
  1. Entity destruction moved to PlayerDeathSystem.handleCorpseEmpty() via EntityManager
  2. lootItems added to network data
  3. modify() overridden to sync private lootItems field on client
Prevention: Update to latest version. Gravestone destruction is now event-driven and reliable.

API Reference

DeathUtils

sanitizeKilledBy(killedBy: unknown): string

Sanitize killer name to prevent injection attacks. Parameters:
  • killedBy - Raw killer name (any type)
Returns: Sanitized string (max 64 chars) or “unknown” Security:
  • Normalizes Unicode to NFKC (prevent homograph attacks)
  • Removes zero-width characters
  • Removes BiDi override characters
  • Removes control characters
  • Removes dangerous HTML characters
Example:

splitItemsForSafeDeath(allItems: InventoryItem[], keepCount: number)

Split items into kept and dropped lists for safe zone deaths. Parameters:
  • allItems - All inventory + equipment items
  • keepCount - Number of items to keep (typically 3)
Returns: { kept: InventoryItem[], dropped: InventoryItem[] } Algorithm:
  1. Tag each item with unit value from manifest
  2. Sort descending by value (tiebreak on original index)
  3. Greedily assign keep-count without expanding stacks
  4. Split into kept and dropped with adjusted quantities
Complexity: O(n log n) on unique item slots Example:

validatePosition(position: Position3D): Position3D | null

Validate and clamp position to world bounds. Parameters:
  • position - Position to validate
Returns: Clamped position or null if completely invalid (NaN/Infinity) Bounds:
  • X/Z: ±10,000 (10km from origin)
  • Y: -50 to 500 (allow some underground, cap at 500m height)
Example:

isPositionInBounds(position: Position3D): boolean

Check if position is within world bounds without clamping. Parameters:
  • position - Position to check
Returns: true if within bounds, false otherwise Example:

PlayerDeathSystem

handlePlayerDeath(playerId: string, killedBy?: string): void

Entry point for death processing. Called by PlayerSystem.handleDeath() when player health reaches 0. Parameters:
  • playerId - Player entity ID
  • killedBy - Optional killer name (sanitized before use)
Behavior:
  • Checks cooldown (prevent spam)
  • Checks duel guard (block during active duel)
  • Sets processing flag (prevent race)
  • Calls processPlayerDeath() (server-only)
  • Error recovery: reset to alive on failure

handleRespawnRequest(playerId: string): void

Handle manual respawn request from client (e.g., “Click here to respawn” button). Parameters:
  • playerId - Player entity ID
Preconditions:
  • Player must be in DYING state
  • Respawn timer must exist
Behavior:
  • Validates preconditions
  • Calls initiateRespawn() immediately (bypasses timer)

processPendingRespawns(): void

Tick-based respawn processor. Called every tick by ServerNetwork. Behavior:
  • Iterate all pending respawn timers
  • Check if timer expired
  • Check if player still in DYING state
  • Call respawnPlayer() for expired timers
  • Drain persist retry queue (single retry per failure)

Configuration

Environment Variables

Constants

Future Enhancements

Wilderness Death (PvP)

Status: Placeholder exists (WildernessDeathHandler.ts) Planned Behavior:
  • Lose all items (no keep-3)
  • Killer gets loot
  • Skull system (protect item count)
  • Unsafe zone detection

Prayer Protection

Status: Not implemented Planned Behavior:
  • Protect Item prayer keeps 1 additional item (keep-4 instead of keep-3)
  • Requires active prayer points
  • Drains prayer on death

Gravestone Upgrades

Status: Not implemented Planned Behavior:
  • Purchasable gravestones with longer TTL
  • Gravestone blessing (extend timer)
  • Gravestone repair (prevent decay)

References

  • PR #1094: Player death system overhaul
  • DeathUtils.ts: Pure utility functions
  • DeathTypes.ts: Type definitions
  • PlayerDeathSystem.ts: Main orchestration
  • DeathStateManager.ts: Death lock database operations
  • SafeAreaDeathHandler.ts: Safe zone death logic