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)
- Player HP reaches 0
- 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
- After transaction:
- Persist equipment clear to DB (retry on failure)
- Persist inventory clear to DB (retry on failure)
- Emit
PLAYER_SET_DEAD event
- Respawn (tick-based, deterministic):
- Return kept items to inventory
- Spawn gravestone with dropped items (5 minute timer)
- Teleport to spawn town
- Clear death lock
- 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
- Player HP reaches 0
- Transaction starts:
- Clear equipment in-memory
- Clear inventory in-memory
- All items marked for ground drop (no keep-3)
- Create death lock
- Commit transaction
- After transaction:
- Persist clears to DB
- Drop all items to ground immediately (no gravestone)
- Items despawn after 2 minutes
- Respawn:
- Teleport to spawn town
- No items returned
- Clear death lock after ground items despawn
Duel Arena Death
- Player HP reaches 0 in duel arena
- No item drops (inventory/equipment preserved)
- Death animation plays
- DuelSystem handles respawn and stakes
- 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:
- Items transition to ground items
- Ground items have additional despawn timer
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
| Event | Data | Description |
|---|
ENTITY_DEATH | entityId, entityType, killedBy, deathPosition | Entity died (use this for death processing) |
PLAYER_SET_DEAD | playerId, isDead, deathPosition? | Player death state changed (use this for client UI) |
PLAYER_DIED | playerId | DEPRECATED - Use PLAYER_SET_DEAD or ENTITY_DEATH instead |
PLAYER_RESPAWNED | playerId, spawnPosition, townName, deathLocation? | Player respawned |
DEATH_RECOVERED | playerId, position, items, killedBy, zoneType | Crash recovery (server restart) |
CORPSE_EMPTY | corpseId, playerId | All items looted from gravestone |
DEATH_HEADSTONE_EXPIRED | headstoneId, playerId | Gravestone timed out |
AUDIT_LOG | action, 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:
- Tag each item with its manifest value
- Sort descending by value (most valuable first)
- Greedily assign keep-count without expanding stacks
- 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)