Skip to main content

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:
  1. Set session state to FINISHED
  2. Transfer loser’s stakes to winner
  3. Restore both players to full health
  4. Teleport to lobby (different spawn points for winner/loser)
  5. Emit duel:completed event
  6. Audit log for economic tracking
  7. 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

  1. Acceptance Reset: Any modification resets both players’ acceptance
  2. Opponent Modified Banner: Warning when opponent changes stakes
  3. Value Imbalance Warning: Alert when risking >50% more than opponent
  4. Duplicate Slot Prevention: Cannot stake same inventory slot twice
  5. 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",
}

Performance Considerations

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:
FeatureOSRS BehaviorHyperscape Implementation
Tick Rate600ms (0.6s)✅ Matches exactly
Challenge Timeout30 seconds✅ 50 ticks = 30s
Countdown3-2-1-FIGHT✅ 1 second per tick
Death Animation~5 seconds✅ 8 ticks = 4.8s
Arena WallsCollision-based✅ CollisionMatrix
Forfeit PillarsClickable objects✅ Implemented
Stake LockingItems locked✅ Server-enforced
Health RestorationFull HP after duel✅ Both players
No Death PenaltyNo 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