Documentation Index Fetch the complete documentation index at: https://hyperscape-ai.mintlify.app/llms.txt
Use this file to discover all available pages before exploring further.
Overview
The Duel Arena is a server-authoritative PvP system where players challenge each other to combat with customizable rules and stakes. Inspired by Old School RuneScape’s Duel Arena, it provides a safe environment for player-vs-player combat with economic stakes.
Key Features:
6 dedicated duel arenas with automatic pooling
10 combat rule toggles (no ranged, no food, etc.)
11 equipment slot restrictions
Item staking with anti-scam protections
3-2-1 countdown before combat
Forfeit mechanics with rule enforcement
Comprehensive audit logging
Duel Flow
1. Challenge Phase
Players must be in the Duel Arena zone to initiate challenges:
// Player A challenges Player B
world . network . send ( "duel:challenge:create" , {
targetId : "player_b_id" ,
targetName : "PlayerB"
});
Requirements:
Both players must be within 15 tiles of each other
Neither player can be in an existing duel
Challenge expires after 30 seconds (50 ticks)
Challenge UI:
Challenger: Sees “Challenge sent” notification
Target: Receives DuelChallengeModal with Accept/Decline buttons
Chat: Red clickable message “PlayerA wishes to duel with you”
2. Rules Screen
Both players negotiate combat rules and equipment restrictions:
Combat Rules (10 toggles):
No Ranged
No Melee
No Magic
No Special Attack
No Prayer
No Potions
No Food
No Movement (freeze at spawn points)
No Forfeit (fight to the death)
Fun Weapons (cosmetic items allowed)
Equipment Restrictions (11 slots):
Head, Cape, Amulet, Weapon, Body, Shield, Legs, Gloves, Boots, Ring, Ammo
Rule Validation:
noForfeit + noMovement is invalid (prevents softlocks)
Both players must accept before proceeding
// Toggle a rule
world . network . send ( "duel:toggle:rule" , {
duelId : "duel_123" ,
rule : "noRanged"
});
// Accept rules
world . network . send ( "duel:accept:rules" , {
duelId : "duel_123"
});
3. Stakes Screen
Players stake items from their inventory:
Staking Mechanics:
Left-click inventory item: Stake 1
Right-click: Context menu (Stake 1, 5, 10, All)
Click staked item: Remove from stakes
Maximum 28 staked items per player
Anti-Scam Features:
Acceptance resets when either player modifies stakes
Warning banner when opponent modifies stakes
Value imbalance warning (>50% difference, >10k gp)
Staked items are locked (cannot be dropped or traded)
// Add stake
world . network . send ( "duel:add:stake" , {
duelId : "duel_123" ,
inventorySlot : 5 ,
quantity : 100
});
// Remove stake
world . network . send ( "duel:remove:stake" , {
duelId : "duel_123" ,
stakeIndex : 0
});
// Accept stakes
world . network . send ( "duel:accept:stakes" , {
duelId : "duel_123"
});
4. Confirmation Screen
Final read-only review before combat:
Displays:
Active rules summary
Disabled equipment summary
“If you win, you receive:” (opponent’s stakes)
“If you lose, they receive:” (your stakes)
Both players’ acceptance status
Warning Banner:
“This is your final chance to review before the duel begins!”
Both players must accept to proceed to countdown.
5. Countdown Phase
Arena Reservation:
System reserves one of 6 available arenas
Returns “No arena available” if all arenas occupied
Teleportation:
Players teleported to arena spawn points (north/south)
Facing each other automatically
Disabled equipment slots are unequipped
Countdown:
3-2-1-FIGHT overlay with color-coded numbers
Players frozen during countdown (cannot move)
Countdown runs on server tick (1 second per tick)
// Server emits countdown ticks
world . emit ( "duel:countdown:tick" , {
duelId : "duel_123" ,
count : 3 , // 3, 2, 1, 0 (FIGHT!)
challengerId : "player_a" ,
targetId : "player_b"
});
6. Fighting Phase
Combat Begins:
Players can attack each other
Rules are enforced server-side
Arena walls prevent escape (collision-based)
DuelHUD displays opponent health and active rules
Rule Enforcement:
// DuelSystem provides rule checking APIs
duelSystem . canUseRanged (playerId); // false if noRanged active
duelSystem . canEatFood (playerId); // false if noFood active
duelSystem . canMove (playerId); // false if noMovement active
duelSystem . canForfeit (playerId); // false if noForfeit active
Forfeit Mechanics:
Click forfeit pillar in arena (if allowed)
Confirmation required (click twice)
Instant loss, opponent wins all stakes
Disconnect Handling:
30-second reconnection timer (50 ticks)
Auto-forfeit if player doesn’t reconnect
Instant loss if noForfeit rule active
7. Resolution
Death:
Loser’s health reaches 0
8-tick delay (4.8 seconds) for death animation
Winner receives all stakes
Both players restored to full health
Both teleported to duel arena lobby
Forfeit:
Forfeiting player loses immediately
Same stake transfer and teleportation
Result Modal:
Victory trophy (🏆) or defeat skull (💀)
Items won/lost with gold values
Total value summary
Server Architecture
DuelSystem
Location: packages/server/src/systems/DuelSystem/index.ts
Main orchestrator for duel sessions (1,609 lines):
export class DuelSystem {
private readonly pendingDuels : PendingDuelManager ;
private readonly arenaPool : ArenaPoolManager ;
private readonly sessionManager : DuelSessionManager ;
private readonly combatResolver : DuelCombatResolver ;
// Public API
createChallenge ( challengerId , challengerName , targetId , targetName );
respondToChallenge ( challengeId , responderId , accept );
toggleRule ( duelId , playerId , rule );
toggleEquipmentRestriction ( duelId , playerId , slot );
acceptRules ( duelId , playerId );
addStake ( duelId , playerId , inventorySlot , itemId , quantity , value );
removeStake ( duelId , playerId , stakeIndex );
acceptStakes ( duelId , playerId );
acceptFinal ( duelId , playerId );
forfeitDuel ( playerId );
// Rule Enforcement
isPlayerInActiveDuel ( playerId ) : boolean ;
canUseRanged ( playerId ) : boolean ;
canUseMelee ( playerId ) : boolean ;
canUseMagic ( playerId ) : boolean ;
canUseSpecialAttack ( playerId ) : boolean ;
canUsePrayer ( playerId ) : boolean ;
canUsePotions ( playerId ) : boolean ;
canEatFood ( playerId ) : boolean ;
canMove ( playerId ) : boolean ;
canForfeit ( playerId ) : boolean ;
getDuelOpponentId ( playerId ) : string | null ;
}
PendingDuelManager
Location: packages/server/src/systems/DuelSystem/PendingDuelManager.ts
Manages challenge requests before duel sessions begin:
30-second expiration timer
Distance validation (15 tiles max)
Disconnect cleanup
Prevents duplicate challenges
export class PendingDuelManager {
createChallenge ( challengerId , challengerName , targetId , targetName );
acceptChallenge ( challengeId , acceptingPlayerId );
declineChallenge ( challengeId , decliningPlayerId );
cancelChallenge ( challengeId );
cancelPlayerChallenges ( playerId );
processTick (); // Distance checks, expiration
}
ArenaPoolManager
Location: packages/server/src/systems/DuelSystem/ArenaPoolManager.ts
Manages 6 duel arenas with automatic pooling:
Arena Layout:
2×3 grid of rectangular arenas
Each arena: 20 tiles wide × 24 tiles long
4-tile gap between arenas
Base coordinates: (70, 0, 90)
Arena Configuration:
interface Arena {
arenaId : number ; // 1-6
inUse : boolean ;
currentDuelId : string | null ;
spawnPoints : [ ArenaSpawnPoint , ArenaSpawnPoint ]; // North, South
bounds : ArenaBounds ;
center : { x : number ; z : number };
}
Collision System:
Registers wall collision on initialization
Blocks perimeter ring OUTSIDE arena bounds
Players can walk to visual wall but not through it
Uses CollisionFlag.BLOCKED in collision matrix
export class ArenaPoolManager {
reserveArena ( duelId ) : number | null ;
releaseArena ( arenaId ) : boolean ;
releaseArenaByDuelId ( duelId ) : boolean ;
getSpawnPoints ( arenaId );
getArenaBounds ( arenaId );
getArenaCenter ( arenaId );
registerArenaWallCollision ( collision );
}
DuelSessionManager
Location: packages/server/src/systems/DuelSystem/DuelSessionManager.ts
CRUD operations for duel sessions:
export interface DuelSession {
duelId : string ;
state : DuelState ; // RULES | STAKES | CONFIRMING | COUNTDOWN | FIGHTING | FINISHED
// Participants
challengerId : string ;
challengerName : string ;
targetId : string ;
targetName : string ;
// Rules & Restrictions
rules : DuelRules ;
equipmentRestrictions : EquipmentRestrictions ;
// Stakes
challengerStakes : StakedItem [];
targetStakes : StakedItem [];
// Acceptance (per screen)
challengerAccepted : boolean ;
targetAccepted : boolean ;
// Arena
arenaId : number | null ;
// Timestamps
createdAt : number ;
countdownStartedAt ?: number ;
fightStartedAt ?: number ;
finishedAt ?: number ;
// Result
winnerId ?: string ;
forfeitedBy ?: string ;
}
DuelCombatResolver
Location: packages/server/src/systems/DuelSystem/DuelCombatResolver.ts
Handles duel outcomes and stake transfers:
export class DuelCombatResolver {
resolveDuel ( session , winnerId , loserId , reason );
returnStakedItems ( session );
private transferStakes ( session , winnerId , loserId , winnerStakes , loserStakes );
private restorePlayerHealth ( playerId );
private teleportToLobby ( playerId , isWinner );
}
Resolution Process:
Set session state to FINISHED
Transfer loser’s stakes to winner
Restore both players to full health
Teleport to lobby (different spawn points for winner/loser)
Emit duel:completed event
Audit log for economic tracking
Clean up session and release arena
Client UI Components
DuelPanel
Location: packages/client/src/game/panels/DuelPanel/
Main duel interface with screen switching:
Screens:
RulesScreen.tsx - Rules and equipment negotiation
StakesScreen.tsx - Item staking with inventory
ConfirmScreen.tsx - Final read-only review
Modal Dimensions:
Rules screen: 450px width
Stakes screen: 650px width (3 panels)
Confirm screen: 520px width (2 columns)
DuelHUD
Location: packages/client/src/game/panels/DuelPanel/DuelHUD.tsx
In-combat overlay showing:
Opponent health bar (large, prominent)
Active rule indicators with icons
Forfeit button (if allowed)
Disconnect status with countdown
Positioning: Fixed at top center, z-index 9000
DuelCountdown
Location: packages/client/src/game/panels/DuelPanel/DuelCountdown.tsx
Full-screen countdown overlay:
Large centered number (200px font)
Color-coded: 3=red, 2=orange, 1=yellow, 0=green
Expanding ring pulse effect
“FIGHT!” display on 0
Auto-hides after fight starts
DuelResultModal
Location: packages/client/src/game/panels/DuelPanel/DuelResultModal.tsx
Post-duel result display:
Victory trophy (🏆) or defeat skull (💀)
Animated entrance (icon pop, title slide)
Items won/lost with gold values
Total value summary
Forfeit indicator
Configuration
Timing Constants
Location: packages/server/src/systems/DuelSystem/config.ts
All timing values in game ticks (600ms each):
export const CHALLENGE_TIMEOUT_TICKS = 50 ; // 30 seconds
export const DISCONNECT_TIMEOUT_TICKS = 50 ; // 30 seconds
export const SESSION_MAX_AGE_TICKS = 3000 ; // 30 minutes
export const DEATH_RESOLUTION_DELAY_TICKS = 8 ; // 4.8 seconds
export const CLEANUP_INTERVAL_TICKS = 17 ; // 10.2 seconds
export const CHALLENGE_CLEANUP_INTERVAL_TICKS = 8 ; // 4.8 seconds
Distance Constants
export const CHALLENGE_DISTANCE_TILES = 15 ; // Max distance for challenges
Arena Configuration
export const ARENA_COUNT = 6 ;
export const ARENA_GRID_COLS = 2 ;
export const ARENA_GRID_ROWS = 3 ;
export const ARENA_BASE_X = 70 ;
export const ARENA_BASE_Z = 90 ;
export const ARENA_Y = 0 ;
export const ARENA_WIDTH = 20 ; // X dimension
export const ARENA_LENGTH = 24 ; // Z dimension
export const ARENA_GAP_X = 4 ;
export const ARENA_GAP_Z = 4 ;
export const SPAWN_OFFSET_Z = 8 ; // Distance from center to spawn points
Spawn Locations
export const LOBBY_SPAWN_WINNER = { x : 102 , y : 0 , z : 60 };
export const LOBBY_SPAWN_LOSER = { x : 108 , y : 0 , z : 60 };
export const LOBBY_SPAWN_CENTER = { x : 105 , y : 0 , z : 60 };
State Machine
The duel system uses an exhaustive state machine:
export type DuelState =
| "RULES" // Negotiating rules and equipment
| "STAKES" // Adding/removing staked items
| "CONFIRMING" // Final read-only review
| "COUNTDOWN" // 3-2-1 countdown (players frozen)
| "FIGHTING" // Active combat
| "FINISHED" ; // Resolution in progress
State Transitions:
RULES → STAKES → CONFIRMING → COUNTDOWN → FIGHTING → FINISHED
↓ ↓ ↓ ↓ ↓
Cancel Cancel Cancel Cancel Death/Forfeit
Exhaustive Switch:
// From DuelSystem.processTick()
switch ( session .state) {
case "RULES" :
case "STAKES" :
case "CONFIRMING" :
// Setup states - no tick processing
break ;
case "COUNTDOWN" :
this . processCountdown (session);
break ;
case "FIGHTING" :
this . processActiveDuel (session);
break ;
case "FINISHED" :
// Resolution in progress
break ;
default :
assertNever ( session .state); // TypeScript exhaustiveness check
}
Network Events
Server → Client
// Challenge events
"duel:challenge:received" // Target receives challenge
"duel:challenge:declined" // Challenge declined
"duel:challenge:expired" // Challenge timed out
"duel:challenge:cancelled" // Challenge cancelled (distance/disconnect)
// Session events
"duel:session:created" // Duel session started
"duel:rules:updated" // Rules changed
"duel:equipment:updated" // Equipment restrictions changed
"duel:acceptance:updated" // Player accepted current screen
"duel:state:changed" // State transition (RULES → STAKES, etc.)
"duel:stakes:updated" // Stakes modified
// Combat events
"duel:countdown:start" // Countdown begins
"duel:countdown:tick" // Countdown number (3, 2, 1, 0)
"duel:fight:start" // Combat begins
"duel:player:disconnected" // Opponent disconnected
"duel:player:reconnected" // Opponent reconnected
"duel:completed" // Duel finished (winner determined)
// Cleanup events
"duel:cancelled" // Duel cancelled
"duel:arena:released" // Arena returned to pool
Client → Server
// Challenge
"duel:challenge:create" // Send challenge
"duel:challenge:respond" // Accept/decline challenge
// Rules
"duel:toggle:rule" // Toggle combat rule
"duel:toggle:equipment" // Toggle equipment restriction
"duel:accept:rules" // Accept rules screen
// Stakes
"duel:add:stake" // Add item to stakes
"duel:remove:stake" // Remove item from stakes
"duel:accept:stakes" // Accept stakes screen
// Confirmation
"duel:accept:final" // Accept final confirmation
// Combat
"duel:forfeit" // Forfeit the duel
"duel:cancel" // Cancel duel (any phase)
Security Features
Server-Authoritative
All duel logic runs on the server:
Client cannot modify rules, stakes, or outcomes
Arena bounds enforced via collision matrix
Stake transfers use database transactions
Rate limiting on all duel operations
Anti-Scam Protections
Acceptance Reset: Any modification resets both players’ acceptance
Opponent Modified Banner: Warning when opponent changes stakes
Value Imbalance Warning: Alert when risking >50% more than opponent
Duplicate Slot Prevention: Cannot stake same inventory slot twice
Read-Only Confirmation: Final screen is non-editable
Audit Logging
Location: packages/server/src/systems/ServerNetwork/services/AuditLogger.ts
All duel events are logged:
AuditLogger . getInstance () . logDuelComplete (
duelId ,
winnerId ,
loserId ,
loserStakes ,
winnerStakes ,
winnerReceivesValue ,
reason
);
AuditLogger . getInstance () . logDuelCancelled (
duelId ,
cancelledBy ,
reason ,
challengerId ,
targetId ,
challengerStakes ,
targetStakes
);
Testing
Unit Tests
Location: packages/server/src/systems/DuelSystem/__tests__/
Comprehensive test coverage:
DuelSystem.test.ts (1,066 lines) - Full state machine testing
ArenaPoolManager.test.ts (233 lines) - Arena pooling
PendingDuelManager.test.ts (456 lines) - Challenge management
Test Utilities:
// From __tests__/mocks.ts
export function createMockWorld () : MockWorld ;
export function createMockPlayer ( id , overrides ) : MockPlayer ;
export function createDuelPlayers () : [ MockPlayer , MockPlayer ];
Example Test:
it ( "transitions to FIGHTING after countdown completes" , () => {
// Progress to COUNTDOWN
duelSystem . acceptRules (duelId , "player1" );
duelSystem . acceptRules (duelId , "player2" );
duelSystem . acceptStakes (duelId , "player1" );
duelSystem . acceptStakes (duelId , "player2" );
duelSystem . acceptFinal (duelId , "player1" );
duelSystem . acceptFinal (duelId , "player2" );
// Advance time past countdown (3 seconds)
vi . advanceTimersByTime ( 3500 );
duelSystem . processTick ();
const session = duelSystem . getDuelSession (duelId);
expect ( session .state) . toBe ( "FIGHTING" );
});
Integration with Other Systems
Combat System
Location: packages/shared/src/systems/shared/combat/CombatSystem.ts
The combat system checks duel rules before allowing actions:
// Check if player can attack with ranged
const duelSystem = this . world . getSystem ( "duel" );
if (duelSystem && ! duelSystem . canUseRanged (attackerId)) {
return ; // Block ranged attack
}
Death System
Location: packages/shared/src/systems/shared/death/PlayerDeathSystem.ts
Death handling differs for duel deaths:
const duelSystem = this . world . getSystem ( "duel" );
if ( duelSystem ?. isPlayerInActiveDuel (playerId)) {
// Duel death - DuelSystem handles resolution
return ;
}
// Normal death - respawn at hospital
Inventory System
Staked items are locked during duels:
// Check if item is staked
const duelSystem = this . world . getSystem ( "duel" );
const session = duelSystem ?. getPlayerDuel (playerId);
if (session && session .state !== "FINISHED" ) {
const stakes = session . challengerStakes . concat ( session .targetStakes);
const isStaked = stakes . some ( s => s .inventorySlot === slot);
if (isStaked) {
return { success : false , error : "Item is staked in duel" };
}
}
Manifest Configuration
Duel Arena Config
Location: manifests/duel-arenas.json
Centralized configuration for arena layout and zones:
{
"arenas" : [
{
"arenaId" : 1 ,
"center" : { "x" : 210 , "z" : 210 } ,
"size" : 16 ,
"spawnPoints" : [
{ "x" : 206 , "y" : 0 , "z" : 210 } ,
{ "x" : 214 , "y" : 0 , "z" : 210 }
] ,
"trapdoorPositions" : [
{ "x" : 203 , "z" : 206 } ,
{ "x" : 203 , "z" : 214 } ,
{ "x" : 217 , "z" : 206 } ,
{ "x" : 217 , "z" : 214 }
]
}
// ... 5 more arenas (IDs 2-6)
] ,
"lobby" : {
"center" : { "x" : 250 , "z" : 295 } ,
"size" : { "width" : 60 , "depth" : 30 } ,
"spawnPoint" : { "x" : 250 , "y" : 0 , "z" : 295 }
} ,
"hospital" : {
"center" : { "x" : 210 , "z" : 295 } ,
"size" : { "width" : 20 , "depth" : 15 } ,
"spawnPoint" : { "x" : 210 , "y" : 0 , "z" : 295 }
} ,
"constants" : {
"arenaSize" : 16 ,
"wallHeight" : 2.5 ,
"wallThickness" : 0.5 ,
"floorColor" : "0xc2b280" ,
"wallColor" : "0x8b7355" ,
"trapdoorColor" : "0x4a3728"
}
}
Arena Layout:
6 arenas arranged in a 2×3 grid
Each arena is 16×16 tiles
Spawn points positioned 4 tiles from center (north/south)
4 trapdoor positions per arena (forfeit pillars)
Lobby area: 60×30 tiles at (250, 295)
Hospital area: 20×15 tiles at (210, 295)
Rule Definitions
export const DUEL_RULE_DEFINITIONS : Record < keyof DuelRules , RuleDefinition > = {
noRanged : {
label : "No Ranged" ,
description : "Ranged attacks are disabled"
} ,
noMelee : {
label : "No Melee" ,
description : "Melee attacks are disabled"
} ,
// ... 8 more rules
};
export const DUEL_RULE_LABELS : Record < keyof DuelRules , string > = {
noRanged : "No Ranged" ,
noMelee : "No Melee" ,
// ... extracted from definitions
};
Equipment Slot Labels
export const EQUIPMENT_SLOT_LABELS : Record < EquipmentSlot , string > = {
head : "Head" ,
cape : "Cape" ,
amulet : "Amulet" ,
weapon : "Weapon" ,
body : "Body" ,
shield : "Shield" ,
legs : "Legs" ,
gloves : "Gloves" ,
boots : "Boots" ,
ring : "Ring" ,
ammo : "Ammo" ,
};
Error Codes
export enum DuelErrorCode {
INVALID_TARGET = "INVALID_TARGET" ,
ALREADY_IN_DUEL = "ALREADY_IN_DUEL" ,
TARGET_BUSY = "TARGET_BUSY" ,
CHALLENGE_PENDING = "CHALLENGE_PENDING" ,
CHALLENGE_NOT_FOUND = "CHALLENGE_NOT_FOUND" ,
DUEL_NOT_FOUND = "DUEL_NOT_FOUND" ,
NOT_PARTICIPANT = "NOT_PARTICIPANT" ,
INVALID_STATE = "INVALID_STATE" ,
INVALID_RULE_COMBINATION = "INVALID_RULE_COMBINATION" ,
ALREADY_STAKED = "ALREADY_STAKED" ,
STAKE_NOT_FOUND = "STAKE_NOT_FOUND" ,
NO_ARENA_AVAILABLE = "NO_ARENA_AVAILABLE" ,
CANNOT_FORFEIT = "CANNOT_FORFEIT" ,
NOT_IN_DUEL = "NOT_IN_DUEL" ,
}
Arena Pooling
6 arenas support up to 6 concurrent duels
Arena reservation is O(n) where n=6 (negligible)
Arena release is O(1) by arena ID or O(n) by duel ID
Session Management
Player-to-session mapping uses Map<string, string> for O(1) lookups
Session cleanup runs every 17 ticks (~10 seconds)
Expired sessions (>30 minutes in setup) are auto-cancelled
Collision Matrix
Arena walls registered once on initialization
Uses existing collision system (no performance overhead)
Wall collision is bitmask-based (very fast)
OSRS Accuracy
The duel system faithfully recreates OSRS mechanics:
Feature OSRS Behavior Hyperscape Implementation Tick Rate 600ms (0.6s) ✅ Matches exactly Challenge Timeout 30 seconds ✅ 50 ticks = 30s Countdown 3-2-1-FIGHT ✅ 1 second per tick Death Animation ~5 seconds ✅ 8 ticks = 4.8s Arena Walls Collision-based ✅ CollisionMatrix Forfeit Pillars Clickable objects ✅ Implemented Stake Locking Items locked ✅ Server-enforced Health Restoration Full HP after duel ✅ Both players No Death Penalty No items lost ✅ Stakes only
Future Enhancements
Potential additions (not yet implemented):
Ranked Duels: ELO rating system
Tournament Mode: Bracket-style competitions
Spectator Mode: Watch ongoing duels
Duel History: Track wins/losses
Leaderboards: Top duelists
Custom Arenas: Player-created arena layouts
Obstacles: Arena hazards (OSRS had this)
Combat System Core combat mechanics used in duels
Death System How death is handled differently in duels
Inventory System Item staking and inventory locking
Collision System Arena wall collision implementation