PvP (Player vs Player) with Arena systems
Structured PvP in MUDs requires moving beyond open-world player killing into systems that guarantee fair matchups, persistent rankings, and reproducible combat data. This guide covers the architectural implementation of arena instances, gear normalization, ELO rating calculations, and combat log serialization necessary for competitive ladder systems. Focus is placed on preventing exploitation through disconnect handling, ensuring database consistency during rating updates, and building external analysis pipelines for balance tuning.

Design arena state machine with room flag isolation
Implement distinct room flags (ROOM_ARENA, ROOM_ARENA_SPECTATOR) that enforce PvP-specific rules. Create an arena_state enum tracking ARENA_CHALLENGE, ARENA_ACTIVE, and ARENA_COOLDOWN phases. Hook into extract_char to force flag cleanup on unexpected disconnects, preventing phantom combat states. Store arena instance IDs in player structs to handle multiple simultaneous arenas.
typedef enum {
ARENA_NONE = 0,
ARENA_CHALLENGE = 1,
ARENA_ACTIVE = 2,
ARENA_COOLDOWN = 3
} arena_state_t;
struct player_arena_data {
int instance_id;
arena_state_t state;
time_t challenge_expiry;
struct char_data *opponent;
struct obj_data *saved_gear;
};
#define IS_ARENA(ch) (ROOM_FLAGGED(IN_ROOM(ch), ROOM_ARENA) || \
ROOM_FLAGGED(IN_ROOM(ch), ROOM_ARENA_SPECTATOR))⚠ Common Pitfalls
- •Failing to clear combat flags on player quit causing immortal state
- •Allowing teleport spells to bypass arena boundary checks
Implement duel handshake with timeout and stake validation
Build a two-phase challenge system using do_duel and do_accept commands. Store challenge metadata in a global lookup table indexed by player ID pairs with automatic expiration via pulse-based cleanup. Validate stake items or currency before combat initiation to prevent inventory desync. Log challenge attempts to a moderation table for dispute resolution.
struct duel_challenge {
struct char_data *challenger;
struct char_data *target;
int stake_amount;
time_t expiry;
int ruleset_flags; /* DUEL_NOMAGIC, DUEL_GEAR_NORMALIZED */
};
void check_duel_expiry(void) {
for (ch = character_list; ch; ch = ch->next) {
if (ch->arena && ch->arena->state == ARENA_CHALLENGE &&
time(NULL) > ch->arena->challenge_expiry) {
send_to_char(ch, "Challenge expired.\r\n");
clear_duel(ch);
}
}
}⚠ Common Pitfalls
- •Not handling target logout during challenge window leaving dangling pointers
- •Race conditions between accept command and automatic timeout cleanup
Build gear normalization and temporary inventory swap
Create save_gear_to_blob and restore_gear_from_blob functions that serialize equipped items to a database BLOB or memory structure before applying standardized stat templates. Use level-bracket tables (1-10, 11-20) to assign predefined equipment sets stored in object templates. Prevent inventory access during normalization by setting a GEAR_SWAPPED flag that blocks get/drop commands.
int normalize_gear(struct char_data *ch, int bracket) {
struct obj_data *gear_blob = NULL;
/* Save current equipment */
for (i = 0; i < NUM_WEARS; i++) {
if (GET_EQ(ch, i))
gear_blob = obj_to_obj(GET_EQ(ch, i), gear_blob);
}
ch->arena->saved_gear = gear_blob;
/* Load template set based on class/bracket */
load_arena_template(ch, GET_CLASS(ch), bracket);
SET_BIT(PLR_FLAGS(ch), PLR_GEAR_SWAPPED);
return 1;
}⚠ Common Pitfalls
- •Memory leaks from unsaved gear states if server crashes during match
- •Players exploiting inventory access during normalization to duplicate items
Integrate ELO rating with atomic database updates
Implement the standard ELO formula with K-factor decay (high K for first 10 matches, then 20, then 10). Calculate ratings immediately upon combat resolution but write to a transaction log before updating player files to prevent corruption. Use floating-point math internally but store as integers scaled by 100 to avoid floating-point drift in flat files. Update ratings before arena cleanup to prevent quit-logging exploits.
def calculate_elo(winner_rating, loser_rating, k_factor=32):
expected_w = 1 / (1 + 10 ** ((loser_rating - winner_rating) / 400))
expected_l = 1 / (1 + 10 ** ((winner_rating - loser_rating) / 400))
new_winner = winner_rating + k_factor * (1 - expected_w)
new_loser = loser_rating + k_factor * (0 - expected_l)
return int(new_winner), int(new_loser)
# K-factor decay based on match count
def get_k_factor(matches_played):
if matches_played < 10:
return 40
elif matches_played < 30:
return 20
return 10⚠ Common Pitfalls
- •Integer division truncation causing rating stagnation at lower values
- •Not handling simultaneous deaths or draws leaving ratings unchanged
Create structured combat logging for balance analysis
Hook into your damage() and hit() functions to append structured JSON entries to a write-ahead log or message queue. Capture attacker/defender stats, dice rolls, final damage, and active affects per round. Implement asynchronous flushing to prevent I/O blocking during high-frequency combat. Include match UUIDs to correlate log entries with specific arena instances for spreadsheet analysis.
void log_combat_event(struct char_data *attacker, struct char_data *defender,
int damage, int dam_type, int arena_id) {
cJSON *event = cJSON_CreateObject();
cJSON_AddNumberToObject(event, "timestamp", time(NULL));
cJSON_AddNumberToObject(event, "match_id", arena_id);
cJSON_AddStringToObject(event, "attacker", GET_NAME(attacker));
cJSON_AddStringToObject(event, "defender", GET_NAME(defender));
cJSON_AddNumberToObject(event, "attacker_str", GET_STR(attacker));
cJSON_AddNumberToObject(event, "defender_ac", GET_AC(defender));
cJSON_AddNumberToObject(event, "damage", damage);
cJSON_AddNumberToObject(event, "dam_type", dam_type);
append_to_wal(event);
cJSON_Delete(event);
}⚠ Common Pitfalls
- •Synchronous file I/O causing visible lag during 2v2 or group arena matches
- •Logging sensitive player location data alongside combat stats violating privacy
Develop ladder views and match history pagination
Implement in-game commands (ladder, rank, history) that query indexed database tables. Create materialized views for top-100 rankings updated hourly to prevent expensive sorts during gameplay. For personal history, paginate at 20 entries per page using LIMIT/OFFSET or cursor-based pagination. Include ELO delta per match and opponent relative ranking to show progress context.
CREATE INDEX idx_elo_rating ON player_ratings(rating DESC);
CREATE INDEX idx_match_date ON match_history(match_date DESC);
CREATE VIEW ladder_top100 AS
SELECT player_id, rating, wins, losses,
RANK() OVER (ORDER BY rating DESC) as rank
FROM player_ratings
WHERE matches >= 10
ORDER BY rating DESC
LIMIT 100;
-- Query for personal history with pagination
SELECT opponent_name, result, elo_delta, match_date
FROM match_history
WHERE player_id = ?
ORDER BY match_date DESC
LIMIT 20 OFFSET ?;⚠ Common Pitfalls
- •Unindexed queries causing lag spikes when players check ladder during peak hours
- •Exposing player IP addresses or account IDs in match metadata
Build tournament bracket generation with automated seeding
Create a bracket system supporting single and double elimination that seeds players by current ELO ranking. Use a power-of-2 normalization function to handle bye rounds for odd participant counts (highest seeds receive byes). Automate arena assignment by linking bracket nodes to arena instance IDs. Implement forfeit detection via heartbeat checks that auto-advance opponents after 5 minutes of disconnect.
struct bracket_node {
int match_id;
struct char_data *player1;
struct char_data *player2;
int arena_instance;
struct bracket_node *next_match;
int winner; /* 0 = undecided, 1 = p1, 2 = p2 */
};
void seed_bracket(struct bracket_node *bracket, int participant_count) {
int next_power = 1;
while (next_power < participant_count) next_power <<= 1;
int byes = next_power - participant_count;
/* Assign byes to highest seeds */
for (int i = 0; i < byes; i++) {
bracket[i].winner = 1; /* auto-advance seed */
advance_winner(&bracket[i]);
}
}⚠ Common Pitfalls
- •Not re-seeding bracket after disqualifications causing empty matches
- •Timezone handling errors in scheduled match times displaying incorrect local times
Implement spectator mode with information masking
Create invisible spectator objects that parse combat events without participating. Mask exact numerical values (HP, mana) by sending percentage bars or vague descriptors (wounded, critical) to prevent real-time coaching via external communication. Block tell and say channels for spectators within arena zones. Log spectator joins with timestamps for moderation audit trails.
void send_to_spectators(struct arena_instance *arena, const char *msg,
int mask_sensitive) {
struct char_data *spec;
char masked_msg[MAX_STRING_LENGTH];
for (spec = arena->spectators; spec; spec = spec->next_spectator) {
if (mask_sensitive && IS_SPECTATOR(spec)) {
/* Convert "deals 45 damage" to "deals heavy damage" */
apply_damage_mask(msg, masked_msg);
send_to_char(spec, masked_msg);
} else {
send_to_char(spec, msg);
}
}
}
#define PLR_SPECTATOR (1 << 15)
#define BLOCKED_FOR_SPECTATOR (COMM_TELL | COMM_SAY)⚠ Common Pitfalls
- •Spectators triggering area-effect spells or social commands affecting arena combat
- •Information leaks through prompt parsing showing exact HP values in title bars
What you built
Deploy arena systems initially with a closed beta group of 10-20 players to identify edge cases in gear normalization and rating calculations before opening to the full player base. Monitor the combat log output weekly using external spreadsheet analysis to identify class imbalance trends. Ensure database backups capture arena state tables to prevent rating corruption during server crashes. Gradually introduce tournament automation only after duel and ladder systems demonstrate stable ELO convergence over 100+ matches.