Skip to content

feat: Multiplayer arena mode — 10-player battle royale with queue matchmaking #30

@pj4533

Description

@pj4533

Overview

Add a multiplayer "Arena" mode where 10 warriors fight simultaneously in a Core War battle royale, using the established (W*W-1)/S scoring formula from the koth.org 94m multi-warrior standard. This coexists alongside the existing 1v1 system as a second tab/leaderboard on the homepage.

Multi-warrior Core War is a well-established format — koth.org has run multi-warrior hills since the 90s. The pMARS manual documents full multi-warrior support, and the Core War FAQ describes the standard scoring formula.

Key discovery: pmars-ts already fully supports multi-warrior battles (up to 36 warriors via MAX_WARRIORS = 36 in ~/Developer/pmars-ts/src/constants.ts). No simulator changes needed for 10-player arenas. The positioning algorithms (posit() and npos() in positioning.ts), round-robin execution with linked-list warrior management, event system (all CoreAccessEvents include warriorId), and RoundResult.winnerId all work correctly with N warriors out of the box.

Problem Statement

Model War currently only supports 1v1 battles. Players can only challenge one opponent at a time, and the gameplay loop is limited to pairwise matchups. Multi-warrior Core War adds a fundamentally different strategic dimension — warriors must survive against multiple opponents simultaneously, rewarding robustness and adaptability over narrow counter-strategies.

Design Decisions

Decision Choice Rationale
Arena size 10 players (fixed) Matches koth.org 94m standard. Achievable to fill with humans. Visually manageable in replay.
Rounds per battle 200 koth.org uses 200 for multi-warrior (2x the 1v1 standard of 100)
Scoring (W*W-1)/S per round Standard Core War multi-warrior formula. W=warriors, S=survivors. Integer division.
Rating Separate arena Glicko-2, human-only pairwise Only human-vs-human placements count for rating. Bots are battlefield hazards but excluded from rating math.
Bot filling Round-robin from 5 stock bots Imp, Dwarf, Paper, Stone, Scissors archetypes
Matchmaking Queue + polling (lazy eval) Agent joins queue with 1 API call, polls for results. No WebSockets, no background workers.
Queue timeout 60 seconds Fill with bots after 60s if <10 players
Coexistence Alongside 1v1 Two tabs on homepage: 1v1 leaderboard + arena leaderboard

Matchmaking Design

The key UX insight: players are AI agents making API calls, not humans clicking buttons. The matchmaking must be dead simple — "point your agent at it."

Two endpoints, that's it:

  1. POST /api/arena/queue — Join the matchmaking queue (returns ticket ID)
  2. GET /api/arena/queue/[ticketId] — Poll for status/results

How it works:

  • Agent calls POST to join. Gets a ticket back immediately.
  • Server groups queue entries into "sessions" of up to 10 players.
  • When 10th player joins a session, battle triggers immediately (inline, during that HTTP request).
  • If 60 seconds pass without filling, next join/poll triggers battle with remaining slots filled by stock bots.
  • Agent polls its ticket until status = completed, gets results.
  • Joins are idempotent: if player already has a waiting ticket, same ticket is returned.
  • Battle trigger uses SELECT ... FOR UPDATE to prevent double-trigger races.
  • No background workers, no WebSockets, no cron jobs. Everything is lazy-evaluated.

Minimal agent example (15 lines of Python):

import requests, time

API = "https://modelwar.com/api/arena/queue"
HEADERS = {"Authorization": "Bearer <api_key>"}

resp = requests.post(API, headers=HEADERS,
    json={"warrior_code": open("warrior.red").read()})
ticket = resp.json()

while ticket["status"] == "waiting":
    time.sleep(ticket.get("poll_interval_ms", 2000) / 1000)
    ticket = requests.get(
        f"{API}/{ticket['ticket_id']}", headers=HEADERS
    ).json()

print(f"Rank: {ticket['results']['your_rank']}/10")
print(f"Rating change: {ticket['results']['rating_change']}")

Queue Race Condition Handling

The FOR UPDATE lock prevents double-triggers when two agents poll simultaneously on an expired session. The join endpoint handles the join-while-triggering race naturally: HAVING COUNT(*) < 10 ensures a new player gets assigned to a new session if the current one is full.

Queue Edge Cases

  • Agent joins and never checks back: expires_at field handles this. Battle fires when any request touches the expired session. Ratings still update. 24h cleanup sweeps mark stale entries as expired.
  • Same agent joins multiple times: Idempotency check returns existing waiting ticket. One queue entry per player at a time.
  • Rate limiting: One active queue entry per player (enforced by idempotency). Poll endpoint returns poll_interval_ms: 2000 for client-side throttling.

Scoring System

Uses the standard Core War multi-warrior formula (W*W-1)/S:

  • W = total number of warriors (10)
  • S = number of survivors at round end
  • Solo survivor: 10*9/1 = 90 points (maximum)
  • Survive with 1 other: 10*9/2 = 45 points
  • Survive with 4 others: 10*9/5 = 18 points
  • Everyone survives: 10*9/10 = 9 points (minimum for survivors)
  • Dead: 0 points

Scores accumulate across 200 rounds. Final placement determined by total score (highest = 1st).

Rating System: Human-Only Pairwise

After a battle, only human-vs-human placements are used for Glicko-2 rating updates. Bots participate in gameplay (they can kill humans, claim territory) but are excluded from rating math.

How it works:

  1. Extract human player placements from final standings (ignore bot positions)
  2. Generate pairwise results between humans only:
    • Human A placed higher than Human B → A=1.0, B=0.0
    • Same placement → both 0.5
  3. Apply dampening factor 1/sqrt(H-1) where H = human count
  4. Feed pairwise results into Glicko-2

Natural scaling:

  • 1 human + 9 bots = 0 pairwise results = no rating change (unranked practice)
  • 2 humans + 8 bots = 1 pairwise result = small rating change
  • 5 humans + 5 bots = 10 pairwise results = moderate rating change
  • 10 humans = 45 pairwise results = full competitive update

Technical Approach

Phase 1: Stock Bots

Create lib/stock-bots.ts with 5 classic Core War archetypes:

  1. ImpMOV 0, 1 (simplest replicator, spreads endlessly)
  2. Dwarf — Periodic DAT bomber (drops bombs at regular intervals)
  3. Paper — Self-replicating scanner (creates copies of itself)
  4. Stone — Scanner-bomber hybrid (scans for enemies, bombs them)
  5. Scissors — Fast scanner (scans wide intervals to find replicators)

Each stored as { name: string, author: string, redcode: string }. Names prefixed with [BOT] (e.g., [BOT] Imp, [BOT] Dwarf).

When filling empty arena slots, cycle through bots round-robin: Imp, Dwarf, Paper, Stone, Scissors, Imp, Dwarf, ...

Phase 2: Database Schema

New migration sql/014_multiplayer_arenas.sql:

arena_queue table (matchmaking queue)

CREATE TABLE arena_queue (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  player_id INTEGER NOT NULL REFERENCES players(id),
  ticket_id UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
  session_id UUID NOT NULL,
  status TEXT NOT NULL DEFAULT 'waiting'
    CHECK (status IN ('waiting', 'matched', 'completed', 'expired')),
  redcode TEXT NOT NULL,
  arena_id INTEGER REFERENCES arenas(id),
  results JSONB,
  joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '60 seconds',
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_arena_queue_session ON arena_queue(session_id)
  WHERE status = 'waiting';
CREATE INDEX idx_arena_queue_ticket ON arena_queue(ticket_id);
CREATE INDEX idx_arena_queue_player_waiting ON arena_queue(player_id)
  WHERE status = 'waiting';

arenas table (completed battles)

CREATE TABLE arenas (
  id SERIAL PRIMARY KEY,
  session_id UUID NOT NULL,
  seed INTEGER NOT NULL,
  total_rounds INTEGER NOT NULL DEFAULT 200,
  status VARCHAR(20) NOT NULL DEFAULT 'running'
    CHECK (status IN ('running', 'completed')),
  started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

arena_participants table

CREATE TABLE arena_participants (
  id SERIAL PRIMARY KEY,
  arena_id INTEGER NOT NULL REFERENCES arenas(id),
  player_id INTEGER REFERENCES players(id),  -- NULL for stock bots
  slot_index INTEGER NOT NULL,  -- 0-9
  is_stock_bot BOOLEAN NOT NULL DEFAULT false,
  stock_bot_name VARCHAR(50),
  redcode TEXT NOT NULL,
  placement INTEGER,  -- 1 = winner, 10 = last
  total_score INTEGER NOT NULL DEFAULT 0,
  arena_rating_before INTEGER,
  arena_rating_after INTEGER,
  arena_rd_before DOUBLE PRECISION,
  arena_rd_after DOUBLE PRECISION,
  UNIQUE(arena_id, slot_index),
  UNIQUE(arena_id, player_id)
);

arena_rounds table (per-round results for replay)

CREATE TABLE arena_rounds (
  id SERIAL PRIMARY KEY,
  arena_id INTEGER NOT NULL REFERENCES arenas(id),
  round_number INTEGER NOT NULL,
  seed INTEGER NOT NULL,
  survivor_count INTEGER NOT NULL,
  winner_slot INTEGER,  -- NULL if tie (multiple survivors)
  scores JSONB NOT NULL,  -- per-slot score for this round
  UNIQUE(arena_id, round_number)
);

New columns on players table

ALTER TABLE players
  ADD COLUMN arena_rating INTEGER NOT NULL DEFAULT 1200,
  ADD COLUMN arena_rd DOUBLE PRECISION NOT NULL DEFAULT 350,
  ADD COLUMN arena_volatility DOUBLE PRECISION NOT NULL DEFAULT 0.06,
  ADD COLUMN arena_wins INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN arena_losses INTEGER NOT NULL DEFAULT 0,
  ADD COLUMN arena_ties INTEGER NOT NULL DEFAULT 0;

Enable RLS on all new tables.

Phase 3: Arena Engine

Create lib/arena-engine.ts:

interface ArenaRoundResult {
  round: number;
  seed: number;
  survivorCount: number;
  winnerSlot: number | null;
  scores: number[];  // per-slot scores for this round
}

interface ArenaResult {
  roundResults: ArenaRoundResult[];
  placements: { slotIndex: number; totalScore: number; placement: number }[];
}

function runArenaBattle(
  warriors: { redcode: string; slotIndex: number }[],
  rounds: number,
  seed: number
): ArenaResult;

Implementation:

  • Assemble all 10 warriors via pmars-ts Assembler
  • Create Simulator with { warriors: 10, coreSize: 8000, maxCycles: 80000, maxProcesses: 8000, minSeparation: 100 }
  • Run 200 rounds
  • Each round: use onRoundEnd event to get winnerId, use onTaskCount to determine which warriors survived
  • Score per round per survivor: Math.floor(90 / S) where S = survivor count
  • Dead warriors score 0 for that round
  • Accumulate scores across 200 rounds
  • Final placement by total score descending (highest = 1st place)

Reference existing patterns from lib/engine.ts (keep 1v1 code untouched).

Phase 4: Arena Rating System

Create lib/arena-glicko.ts:

Human-only pairwise rating: After a battle, extract only human player placements and generate pairwise results between them. Bots participate in gameplay but are excluded from rating math.

function calculateArenaRatings(
  participants: {
    playerId: number | null;  // null for bots
    placement: number;
    currentRating: number;
    currentRd: number;
    currentVolatility: number;
  }[]
): Map<number, { rating: number; rd: number; volatility: number }>;
  • Filter to human participants only (playerId !== null)
  • Generate pairwise results: higher placement = 1.0, lower = 0.0, equal = 0.5
  • Apply dampening factor 1/sqrt(H-1) where H = human count
  • Process all pairwise results in one Glicko-2 rating period per player
  • 1 human = return empty map (no rating update)
  • Use existing lib/glicko.ts functions as reference (keep untouched)

Phase 5: Queue & Battle API

POST /api/arena/queue (authenticated)

File: app/api/arena/queue/route.ts

1. Authenticate via Bearer token (reuse lib/auth.ts)
2. Check idempotency: existing 'waiting' entry for this player_id?
   → If yes, return existing ticket
3. Extract warrior_code from request body
4. Validate warrior via pmars-ts Assembler (same as challenge route)
5. BEGIN transaction
6. Find open session:
   SELECT session_id FROM arena_queue
   WHERE status = 'waiting'
   GROUP BY session_id
   HAVING COUNT(*) < 10
   ORDER BY MIN(joined_at) LIMIT 1
7. If no open session: generate new UUID as session_id
8. INSERT into arena_queue (player_id, session_id, redcode, ...)
9. Count waiting entries for this session
10. If count = 10: trigger battle immediately (call triggerArenaBattle)
11. COMMIT
12. Return { ticket_id, status, position, queue_size: 10, poll_interval_ms: 2000, expires_at }

GET /api/arena/queue/[ticketId] (authenticated)

File: app/api/arena/queue/[ticketId]/route.ts

1. Authenticate
2. Look up ticket by ticket_id, verify player_id ownership
3. If status = 'completed': return results { rank, score, rating_change, arena_id, arena_url }
4. If status = 'waiting':
   a. Check if session expired (NOW() > expires_at of oldest entry in session)
   b. If expired: call triggerArenaBattle(session_id) with bot-filling
   c. If not expired: return { status: 'waiting', position, expires_at }
5. Return response

Battle Trigger Logic

File: lib/arena-trigger.ts

async function triggerArenaBattle(sessionId: string): Promise<void> {
  // 1. BEGIN transaction
  // 2. SELECT ... FROM arena_queue WHERE session_id = ? AND status = 'waiting' FOR UPDATE
  //    (row-level lock prevents double-trigger)
  // 3. If no waiting rows (already triggered): return early
  // 4. Collect warrior redcodes from waiting entries
  // 5. Fill remaining slots (up to 10) with stock bots round-robin
  // 6. Generate random seed
  // 7. Run runArenaBattle() from arena engine
  // 8. Calculate arena Glicko-2 rating updates (human-only pairwise)
  // 9. INSERT arena record
  // 10. INSERT arena_participants (one per slot, with placements and scores)
  // 11. INSERT arena_rounds (one per round, with per-slot scores)
  // 12. UPDATE arena_queue: status='completed', arena_id=..., results=JSONB
  // 13. UPDATE players: arena_rating, arena_rd, arena_volatility, arena_wins/losses/ties
  // 14. COMMIT
}

Additional Endpoints

GET /api/arenas/[id] (public) — File: app/api/arenas/[id]/route.ts

  • Arena details: participants, placements (1st-10th), scores, rating changes
  • Includes warrior names, stock bot indicators, slot indices

GET /api/arenas/[id]/replay (public) — File: app/api/arenas/[id]/replay/route.ts

  • Returns all 10 warrior redcodes, round results, seeds, simulator settings
  • Same pattern as existing /api/battles/[id]/replay but for N warriors

GET /api/arena-leaderboard (public) — File: app/api/arena-leaderboard/route.ts

  • Top 100 players ranked by arena_rating - 2 * arena_rd (conservative rating)
  • Same pattern as existing /api/leaderboard

DB Functions in lib/db.ts

Add arena CRUD functions:

  • createArenaQueueEntry(playerId, sessionId, redcode) → queue entry
  • getQueueEntryByTicket(ticketId) → queue entry or null
  • getWaitingEntryForPlayer(playerId) → existing waiting entry or null
  • findOpenSession() → session_id or null
  • getSessionEntries(sessionId) → waiting entries for session
  • createArena(sessionId, seed, totalRounds) → arena record
  • createArenaParticipant(arenaId, ...) → participant record
  • createArenaRound(arenaId, ...) → round record
  • updateQueueEntryCompleted(sessionId, arenaId, results) → update all entries
  • getArenaById(id) → arena with participants
  • getArenaLeaderboard() → top 100 by conservative arena rating

Phase 6: Arena Replay System

Color Palette

Create lib/arena-colors.ts with 10 hand-picked visually distinct colors. Each warrior needs three variants (active/fading/territory):

Slot Name Active (bright) Fading Territory (dark)
0 Green #39FF14 (57,255,20) (30,160,15) (10,74,10)
1 Magenta #FF00FF (255,0,255) (160,0,160) (58,0,58)
2 Cyan #00CCFF (0,204,255) (0,120,160) (0,50,80)
3 Orange #FF8800 (255,136,0) (160,85,0) (80,42,0)
4 Yellow #FFDD00 (255,221,0) (160,140,0) (80,70,0)
5 Red #FF3333 (255,51,51) (160,32,32) (80,16,16)
6 Purple #9966FF (153,102,255) (96,64,160) (48,32,80)
7 Teal #00FFAA (0,255,170) (0,160,107) (0,80,53)
8 Pink #FF66AA (255,102,170) (160,64,107) (80,32,53)
9 White #DDDDFF (221,221,255) (140,140,160) (70,70,80)

Territory map: Uint8Array[8000] with values 0 (empty) through 10 (warrior slot + 1). Works as-is since Uint8Array supports 0-255.

Replay Worker

Create workers/arena-replay-worker.ts:

  • Accepts array of 10 warrior redcodes (instead of challenger/defender)
  • Tracks warriorTasks: number[] (array of 10) instead of challengerTasks/defenderTasks
  • Same prescan/step/run_to_end message protocol as existing workers/replay-worker.ts
  • Uses pmars-ts event system (already includes warriorId on all events)
  • onTaskCount callback populates the tasks array for all 10 warriors
  • onRoundEnd provides winnerId (works for N warriors)

New init message format:

{ type: 'init', warriors: { name: string; redcode: string; slotIndex: number }[], seed: number, settings: SimSettings, roundIndex: number }

New events message format:

{ type: 'events', events: CoreAccessEvent[], cycle: number, warriorTasks: number[] }

Replay Components

Create components/arena-replay/:

ArenaReplayViewer.tsx — Main viewer component (based on existing components/replay/ReplayViewer.tsx):

  • Loads replay data from /api/arenas/[id]/replay
  • Spawns arena replay worker
  • Real-time state management via reducer pattern
  • Shows 10 warriors with their colors, names, task counts, and alive/dead status

ArenaCoreCanvas.tsx — N-color rendering (generalized from components/replay/CoreCanvas.tsx):

  • Same 100x80 grid, 4px cells, 400x320 canvas
  • Color lookup: palette[territory - 1] for territory > 0
  • Same activity decay (3 cycles) as existing canvas
  • Territory assignment: newTerritory[addr] = event.warriorId + 1

ArenaPlaybackControls.tsx — Shows all 10 warriors:

  • List of warriors with color dots, names, task counts
  • When dead: show "DEAD" with strikethrough
  • When winner: show "WIN" highlighted
  • Play/pause/step/jump-to-end controls (same as existing)

arena-replay-logic.ts — Generalized state management:

interface ArenaReplayState {
  status: 'loading' | 'scanning' | 'ready' | 'playing' | 'paused' | 'finished' | 'error';
  cycle: number;
  maxCycles: number;
  endCycle: number | null;
  territoryMap: Uint8Array;   // values 0..10
  activityMap: Uint8Array;    // values 0..3 (unchanged)
  warriorCount: number;       // 10
  warriorTasks: number[];     // [tasks_w0, ..., tasks_w9]
  warriorAlive: boolean[];    // [alive_w0, ..., alive_w9]
  winner: number | null;      // warrior index, or null for tie
}

Phase 7: Frontend / UI

Homepage — Modify app/page.tsx

  • Add tab switcher: "1v1 Leaderboard" | "Arena Leaderboard"
  • Arena leaderboard shows players ranked by arena_rating - 2 * arena_rd
  • Include arena-specific stats (arena W/L/T, arena rating)
  • Same retro terminal styling as existing leaderboard

Arena Detail Page — Create app/arenas/[id]/page.tsx

  • Placements (1st-10th) with warrior names, total scores, rating changes
  • Stock bots identified with [BOT] prefix
  • Replay link for viewing rounds
  • Same terminal/retro styling as battle detail pages

Arena Replay Page — Create app/arenas/[id]/replay/page.tsx

  • Uses ArenaReplayViewer component
  • Round selector to navigate 200 rounds
  • Same layout as existing /battles/[id]/rounds/[round] pages

Player Profile — Modify app/players/[id]/page.tsx

  • Add arena rating display alongside 1v1 rating
  • Arena W/L/T stats
  • Arena battle history section (separate from 1v1 history)

Files to Modify/Create

New Files

File Purpose
lib/stock-bots.ts 5 stock bot archetypes with Redcode
lib/arena-engine.ts Multi-warrior battle engine with (W*W-1)/S scoring
lib/arena-glicko.ts Human-only pairwise Glicko-2 for arenas
lib/arena-colors.ts 10-color palette with active/fading/territory variants
lib/arena-trigger.ts Shared battle trigger logic with FOR UPDATE locking
sql/014_multiplayer_arenas.sql Migration for arena_queue, arenas, arena_participants, arena_rounds tables
app/api/arena/queue/route.ts POST: join matchmaking queue
app/api/arena/queue/[ticketId]/route.ts GET: poll queue ticket status
app/api/arenas/[id]/route.ts GET: arena details and results
app/api/arenas/[id]/replay/route.ts GET: arena replay data
app/api/arena-leaderboard/route.ts GET: top 100 arena players
workers/arena-replay-worker.ts Web Worker for N-warrior replay simulation
components/arena-replay/ArenaReplayViewer.tsx Main arena replay viewer component
components/arena-replay/ArenaCoreCanvas.tsx N-color core canvas rendering
components/arena-replay/ArenaPlaybackControls.tsx 10-warrior playback controls
components/arena-replay/arena-replay-logic.ts Arena replay state management
app/arenas/[id]/page.tsx Arena detail/results page
app/arenas/[id]/replay/page.tsx Arena replay page

Modified Files

File Changes
app/page.tsx Add tab switcher for 1v1 vs Arena leaderboard
app/players/[id]/page.tsx Add arena rating, stats, and battle history
lib/db.ts Add arena CRUD functions (queue, arenas, participants, rounds, leaderboard)

Unchanged

File Why
~/Developer/pmars-ts/ Already supports 10+ warriors natively (MAX_WARRIORS=36)
lib/engine.ts 1v1 battle engine untouched
lib/glicko.ts 1v1 Glicko-2 untouched
components/replay/* Existing 1v1 replay components untouched
All existing API routes 1v1 challenge flow unchanged

Acceptance Criteria

Functional Requirements

  • Agent can join arena queue with POST /api/arena/queue (single API call with warrior code)
  • Agent can poll GET /api/arena/queue/[ticketId] for status and results
  • Joining queue is idempotent (same ticket returned if already waiting)
  • Battle triggers immediately when 10th player joins a session
  • Battle triggers with bot-filling when session expires (60s timeout)
  • Battle trigger is race-condition safe (FOR UPDATE locking)
  • Stock bots fill remaining slots round-robin (Imp, Dwarf, Paper, Stone, Scissors)
  • Scoring uses (W*W-1)/S = 90/S per round per survivor (integer division)
  • 200 rounds per arena battle
  • Final placements determined by total accumulated score
  • Arena Glicko-2 ratings use human-only pairwise decomposition
  • 1 human = no rating change; 2+ humans = proportional rating updates
  • Dampening factor 1/sqrt(H-1) prevents oversized swings
  • Arena leaderboard shows top 100 by conservative arena rating
  • Homepage has two tabs: 1v1 and Arena leaderboards
  • Arena replay renders with 10 distinct warrior colors
  • Player profile shows arena rating alongside 1v1 rating

Non-Functional Requirements

  • npm run build passes with no TypeScript errors
  • npm test passes with >= 80% coverage on all metrics
  • No changes to existing 1v1 system (battles, ratings, replay, API)
  • Migration sql/014_multiplayer_arenas.sql creates all tables with RLS enabled

Verification Plan

  1. Unit tests: Stock bots assemble successfully via pmars-ts, arena engine produces valid results with correct (W*W-1)/S scoring, arena Glicko-2 rating updates work correctly with human-only pairwise, queue idempotency returns same ticket
  2. Integration test: Join queue → trigger battle (10 players or timeout+bots) → verify arena + participants + rounds stored, ratings updated
  3. Manual test:
    • npm run dev and navigate to arena tab on homepage
    • Use curl/httpie to POST /api/arena/queue, poll ticket, verify battle triggers after timeout
    • Verify replay renders with 10 distinct warrior colors on the 100x80 grid
    • Check arena leaderboard updates after battle completion
    • Verify player profile shows both 1v1 and arena ratings
  4. Build: npm run build passes
  5. Test suite: npm test passes, all coverage thresholds >= 80% maintained

Implementation Order (suggested sub-issues)

  1. Stock bots + arena engine + arena scoring + tests
  2. Database migration + DB functions
  3. Queue API + battle trigger + tests
  4. Arena rating system + tests
  5. Arena replay system (worker + components)
  6. Frontend (homepage tabs, arena pages, player profile updates)
  7. Polish (ArenaHeroReplay, stale queue cleanup)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions