Player Death System Architecture
Last Updated: March 26, 2026Related 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
valuefield - 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
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)
- Persist equipment clear to database
- Persist inventory clear to database
- Retry queue handles failures
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:- 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)
4. Gravestone Privacy
Gravestone loot is privacy-preserving (OSRS-accurate):lootItemsarray is stripped from network broadcast- Only
lootItemCount(number) is synced to all clients - Full loot data sent only to interacting player via
corpseLootpacket - Empty gravestone guard uses synced
lootItemCountfield
5. Persist Retry Queue
Post-transaction persist failures are retried once:- 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_LOGevents 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
- Add to retry queue
- Single retry on next tick
AUDIT_LOGevent on retry failure- Bounded queue (max 100 entries)
- Death lock persists kept items to database
- On reconnect: check for active death lock
- Emit
AUDIT_LOGevent (ops visibility) - Recover kept items from death lock
- Complete respawn flow
Event Migration
PLAYER_DIED → PLAYER_SET_DEAD
Old Event (deprecated):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
- Primary key on
player_id(one lock per player) - Index on
expires_atfor cleanup queries
cleanupExpiredLocks().
Testing
Unit Tests
DeathUtils.test.ts (51 tests):sanitizeKilledBy()- XSS, Unicode normalization, BiDi overrides, control characterssplitItemsForSafeDeath()- OSRS keep-3, stack handling, edge cases, OOM regressionvalidatePosition()- NaN/Infinity handling, clamping, bounds checkingisPositionInBounds()- Boundary validation
- Duel guard blocks respawn during active duel
- Death processing race guard prevents duplicate ENTITY_DEATH
- Tick-based respawn timing
- Persist retry queue drain
PLAYER_DIED→PLAYER_SET_DEADmigration
modify()network sync logiclootItemCount=0clears 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
- Check for NaN/Infinity
- Clamp to world bounds (±10km from origin)
- Clamp height (-50m to 500m)
- Reject completely invalid positions
- Early return for
gravestone_prefix inhandlePlayerDeath() - Prevents gravestone destruction from triggering false player death
- Performance optimization (not security boundary - real gate is
isServercheck)
Server-Only Processing
Death processing is strictly server-only:Duel Guard
Respawn is blocked during active duels: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)
- Fallback: Tick-based expiration in
Monitoring & Observability
Audit Events
The system emitsAUDIT_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 commitAUDIT_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
- Check server logs for
DEATH_PERSIST_DESYNCtag - Check for transaction errors in death processing
- Query death locks:
SELECT * FROM death_locks WHERE player_id = ?
- Nested transaction deadlock in SQLite
clearEquipmentAndReturn()andclearInventoryImmediate()opened transactions insideexecuteInTransaction()
Issue: Equipment duplicates on death
Symptoms:- Player dies
- Equipment appears in both gravestone and inventory after respawn
- Item duplication exploit
- Equipment clear failed silently due to transaction deadlock
- Gravestone created with equipment items
- Player respawned with equipment still equipped
Issue: Gravestone shows stale items after looting
Symptoms:- Player loots gravestone
- Gravestone entity persists with old items
- Next death shows duplicate items in gravestone
removeItem()usedsetTimeout(500ms)for self-destruct (unreliable)getNetworkData()only sentlootItemCount, never actual items array- Client entity had no items → empty loot window
- Entity destruction moved to
PlayerDeathSystem.handleCorpseEmpty()viaEntityManager lootItemsadded to network datamodify()overridden to sync privatelootItemsfield on client
API Reference
DeathUtils
sanitizeKilledBy(killedBy: unknown): string
Sanitize killer name to prevent injection attacks. Parameters:killedBy- Raw killer name (any type)
- Normalizes Unicode to NFKC (prevent homograph attacks)
- Removes zero-width characters
- Removes BiDi override characters
- Removes control characters
- Removes dangerous HTML characters
splitItemsForSafeDeath(allItems: InventoryItem[], keepCount: number)
Split items into kept and dropped lists for safe zone deaths. Parameters:allItems- All inventory + equipment itemskeepCount- Number of items to keep (typically 3)
{ kept: InventoryItem[], dropped: InventoryItem[] }
Algorithm:
- Tag each item with unit value from manifest
- Sort descending by value (tiebreak on original index)
- Greedily assign keep-count without expanding stacks
- Split into kept and dropped with adjusted quantities
validatePosition(position: Position3D): Position3D | null
Validate and clamp position to world bounds. Parameters:position- Position to validate
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)
isPositionInBounds(position: Position3D): boolean
Check if position is within world bounds without clamping. Parameters:position- Position to check
true if within bounds, false otherwise
Example:
PlayerDeathSystem
handlePlayerDeath(playerId: string, killedBy?: string): void
Entry point for death processing. Called byPlayerSystem.handleDeath() when player health reaches 0.
Parameters:
playerId- Player entity IDkilledBy- Optional killer name (sanitized before use)
- 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
- Player must be in DYING state
- Respawn timer must exist
- Validates preconditions
- Calls
initiateRespawn()immediately (bypasses timer)
processPendingRespawns(): void
Tick-based respawn processor. Called every tick byServerNetwork.
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