Feature 11: PVP System
Overview
Arena PVP with instant resolution (no wait timer), ELO-based matchmaking, stat normalization for fairness, 1v1 and 3v3 brackets, seasonal ratings with soft resets, and leaderboards. PVP matches use the same combat engine as PvE but with normalized stats.
Dependencies
- Feature 06 (Combat Engine) — same simulation engine with PVP rules
- Feature 05 (Skill Queue) — players use their configured queues
Technical Tasks
1. Database Migrations
- Create migration
0010_create_pvp.sql:pvp_ratings: character_id, bracket (‘1v1’/‘3v3’), rating (default 1000), season, wins, losses (PK: character_id + bracket + season)pvp_matches: id, bracket, season, team_a (UUID[]), team_b (UUID[]), winner (‘a’/‘b’/‘draw’), match_log (JSONB), rating_changes (JSONB), resolved_at
2. PVP Stat Normalization (crates/game)
- Create
src/pvp.rs:normalize_character(character: &CharacterSnapshot) -> PvpParticipant:- Normalize all base attributes to a baseline (e.g., 30 per attribute)
- Keep weapon type and skill queue (strategy matters)
- Keep gear enchantments/effects (effects matter, raw stats don’t)
- Strip raw stat bonuses from gear
- Set all participants to same HP pool
- This ensures Patron speed bonus has zero PVP impact (arena resolves instantly)
- Higher-level characters have more skills available (real advantage) but not stat advantage
3. ELO Matchmaking (crates/game)
- Create
src/pvp_matchmaking.rs:calculate_elo_change(winner_rating: i32, loser_rating: i32) -> (i32, i32):- Standard ELO formula with K-factor 32
- Winner gains, loser loses (different amounts based on rating gap)
is_viable_match(rating_a: i32, rating_b: i32, wait_seconds: u64) -> bool:- Start with ±150 rating window
- Expand by 50 per 30 seconds of waiting
- Max window: ±500
4. PVP Queue (Redis)
- PVP queue stored in Redis sorted set:
pvp:queue:{bracket}with score = rating join_queue(redis, character_id, bracket, rating): ZADDleave_queue(redis, character_id, bracket): ZREMfind_match(redis, bracket) -> Option<(CharId, CharId)>:- Scan sorted set for viable matches
- Pop both matched players atomically
5. PVP Worker (crates/workers)
- Create
src/pvp_worker.rs:- Matchmaker (runs every 5 seconds via scheduler):
- Call
find_matchfor each bracket - For each match found: enqueue
pvp-resolvejob (immediate)
- Call
- Match resolver (handles
pvp-resolvequeue):- Load both characters’ snapshots
- Normalize stats
- Run combat simulation (same engine, PVP encounter rules)
- Determine winner
- Calculate ELO changes
- Write match result + rating updates to DB
- Create notifications for both players
- Matchmaker (runs every 5 seconds via scheduler):
6. PVP Seasons (crates/game)
- Create
src/pvp_seasons.rs:- Season duration: ~3 months
soft_reset_rating(current: i32) -> i32: compress toward 1000 (e.g., new = 1000 + (current - 1000) / 2)- Season rewards based on final rating tier:
- Bronze (< 1200): title
- Silver (1200-1499): title + portrait frame
- Gold (1500-1799): title + portrait + materials
- Diamond (1800+): title + portrait + exclusive seasonal item
7. PVP Routes (crates/api)
- Create
src/routes/pvp.rs:POST /api/pvp/queue:- Body:
{ character_id, bracket } - Validate: character has active skill queue, has gear, not already in queue
- Add to Redis PVP queue
- Return
{ status: "queued" }
- Body:
DELETE /api/pvp/queue:- Body:
{ character_id, bracket } - Remove from queue
- Body:
GET /api/pvp/queue/status?character_id=:- Return: in_queue (bool), match_found (bool), match_id (if found)
- Polled every 10 seconds by client
GET /api/pvp/history?character_id=:- Paginated match history: opponent, result, rating change, date
GET /api/pvp/leaderboard/:bracket:- Top 100 by rating from Redis sorted set
- Include: rank, character name, rating, wins, losses
GET /api/pvp/ratings?character_id=:- Current ratings for all brackets + season
8. Client — PVP UI
- Create
routes/(game)/pvp/+page.svelte:- Bracket selector (1v1, 3v3)
- Current rating, rank, W/L record
- “Enter Queue” / “Leave Queue” button
- Queue status indicator with polling (10s interval)
- Match result modal when match completes
- Create
routes/(game)/pvp/history/+page.svelte:- Match history list with replay viewer (same as PvE run viewer)
- Create
routes/(game)/pvp/leaderboard/+page.svelte:- Top 100 table per bracket
- Highlight current player’s rank
Tests
Unit Tests
normalize_character: all attributes set to baselinenormalize_character: gear effects preserved, raw stats strippednormalize_character: HP pool equalizedcalculate_elo_change: higher rated player gains less from beating lower ratedcalculate_elo_change: lower rated player gains more from upsetcalculate_elo_change: sum of changes is approximately zerois_viable_match: accepts ±150 at 0 seconds waitis_viable_match: rejects ±200 at 0 seconds, accepts at 30 secondssoft_reset_rating: compresses 1500 → 1250, 500 → 750- PVP combat simulation: deterministic with same seed
- PVP combat simulation: stat normalization means level 10 vs level 50 is fair on raw stats
Integration Tests
POST /api/pvp/queueadds to Redis sorted setDELETE /api/pvp/queueremoves from RedisPOST /api/pvp/queuerejects without active skill queue- Matchmaker finds viable match and enqueues resolution
- PVP worker resolves match, writes result, updates ratings
GET /api/pvp/queue/statusshows match_found after resolutionGET /api/pvp/historyreturns match resultsGET /api/pvp/leaderboard/1v1returns top 100 sorted by ratingGET /api/pvp/ratingsreturns current season ratings- Concurrent queue joins: two players with close ratings → matched within 10 seconds