-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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:
POST /api/arena/queue— Join the matchmaking queue (returns ticket ID)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 UPDATEto 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_atfield handles this. Battle fires when any request touches the expired session. Ratings still update. 24h cleanup sweeps mark stale entries asexpired. - 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: 2000for 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:
- Extract human player placements from final standings (ignore bot positions)
- Generate pairwise results between humans only:
- Human A placed higher than Human B → A=1.0, B=0.0
- Same placement → both 0.5
- Apply dampening factor
1/sqrt(H-1)where H = human count - 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:
- Imp —
MOV 0, 1(simplest replicator, spreads endlessly) - Dwarf — Periodic DAT bomber (drops bombs at regular intervals)
- Paper — Self-replicating scanner (creates copies of itself)
- Stone — Scanner-bomber hybrid (scans for enemies, bombs them)
- 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
Simulatorwith{ warriors: 10, coreSize: 8000, maxCycles: 80000, maxProcesses: 8000, minSeparation: 100 } - Run 200 rounds
- Each round: use
onRoundEndevent to getwinnerId, useonTaskCountto 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.tsfunctions 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]/replaybut 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 entrygetQueueEntryByTicket(ticketId)→ queue entry or nullgetWaitingEntryForPlayer(playerId)→ existing waiting entry or nullfindOpenSession()→ session_id or nullgetSessionEntries(sessionId)→ waiting entries for sessioncreateArena(sessionId, seed, totalRounds)→ arena recordcreateArenaParticipant(arenaId, ...)→ participant recordcreateArenaRound(arenaId, ...)→ round recordupdateQueueEntryCompleted(sessionId, arenaId, results)→ update all entriesgetArenaById(id)→ arena with participantsgetArenaLeaderboard()→ 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 ofchallengerTasks/defenderTasks - Same prescan/step/run_to_end message protocol as existing
workers/replay-worker.ts - Uses pmars-ts event system (already includes
warriorIdon all events) onTaskCountcallback populates the tasks array for all 10 warriorsonRoundEndprovideswinnerId(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
ArenaReplayViewercomponent - 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 buildpasses with no TypeScript errors -
npm testpasses with >= 80% coverage on all metrics - No changes to existing 1v1 system (battles, ratings, replay, API)
- Migration
sql/014_multiplayer_arenas.sqlcreates all tables with RLS enabled
Verification Plan
- 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
- Integration test: Join queue → trigger battle (10 players or timeout+bots) → verify arena + participants + rounds stored, ratings updated
- Manual test:
npm run devand 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
- Build:
npm run buildpasses - Test suite:
npm testpasses, all coverage thresholds >= 80% maintained
Implementation Order (suggested sub-issues)
- Stock bots + arena engine + arena scoring + tests
- Database migration + DB functions
- Queue API + battle trigger + tests
- Arena rating system + tests
- Arena replay system (worker + components)
- Frontend (homepage tabs, arena pages, player profile updates)
- Polish (ArenaHeroReplay, stale queue cleanup)