Guides

Magic Systems with ROM and Merc spell tables

Stock ROM and Merc distributions implement magic as flat mana consumption with binary success checks. This guide provides a retrofit path for adding strategic depth through reagent costs, layered resistances, and granular combat messaging without breaking legacy area files. The sequence assumes a working ROM 2.4b6 or Merc 2.2 codebase and focuses on minimal-invasive changes to fight.c, magic.c, and tables.c.

2-3 hours for implementation, plus ongoing balancing7 steps
Magic Systems with ROM and Merc spell tables illustration
Placeholder illustration shown while custom artwork is being produced.
1

Audit Skill Table Alignment

Locate the skill_table array in tables.c and verify that the SKILL_ defines in merc.h match the table indices exactly. Count the entries and ensure the last element is a null terminator or sentinel value as expected by the skill_lookup() function. Mismatches here cause off-by-one errors that manifest as wrong spells being cast.

merc.h_tables.c
/* In merc.h, verify count matches tables.c */
#define SKILL_FIREBALL     40
#define SKILL_LAST         150  /* Ensure this matches array size */

/* In tables.c */
struct skill_type skill_table[MAX_SKILL] = {
    /* slot 0 must be reserved or skill_lookup breaks */
    { "reserved", {0,0,0,0,0}, NULL, 0, TAR_IGNORE, POS_STANDING, NULL, 0, 0, 0 },
    /* ... rest of table must align with defines ... */
};

⚠ Common Pitfalls

  • Hardcoding array sizes without referencing MAX_SKILL macro
  • Assuming skill number 0 is valid for player use
  • Forgetting to update skill_lookup() binary search bounds after adding entries
2

Implement Reagent Deduction Layer

Modify spell functions in magic.c to check for object presence before mana consumption. Create a deduct_cost() function that verifies inventory for specific vnums (reagents) before allowing the cast. Place this check before the mana deduction to prevent partial resource consumption on failed casts.

magic.c
bool deduct_reagent(CHAR_DATA *ch, int vnum, int count) {
    OBJ_DATA *obj;
    int found = 0;
    
    for (obj = ch->carrying; obj != NULL; obj = obj->next_content) {
        if (obj->pIndexData->vnum == vnum) {
            found += obj->count;
            if (found >= count) break;
        }
    }
    
    if (found < count) {
        send_to_char("You lack the required components.\n\r", ch);
        return FALSE;
    }
    
    /* Actually remove from inventory */
    extract_obj_by_vnum(ch, vnum, count);
    return TRUE;
}

⚠ Common Pitfalls

  • Checking reagents after mana is spent, allowing free attempts
  • Not handling stacked objects (obj->count > 1) correctly
  • Failing to send failure message before returning FALSE, leaving player confused
3

Insert Resistance Calculation

Intercept damage in fight.c before apply_damage() is called. Add a check_resistance() function that aggregates modifiers from victim->race resist vector, affected_by bits, and equipment flags. Return a percentage scalar (0-200 where 100 is normal) rather than modifying damage directly, allowing logging of pre-mitigation values.

fight.c
int check_resistance(CHAR_DATA *victim, int dam_type, int spell_level) {
    int resist = 100;
    
    /* Race table lookup */
    if (dam_type < MAX_RESIST && race_table[victim->race].resist[dam_type])
        resist += race_table[victim->race].resist[dam_type];
    
    /* Equipment and affect checks */
    if (IS_AFFECTED(victim, AFF_RESIST_FIRE) && dam_type == DAM_FIRE)
        resist -= 50;
    
    /* Saving throw partial resistance */
    resist += (saving_throw(victim) - spell_level) * 2;
    
    /* Clamp to prevent overflow or healing from damage */
    return URANGE(0, resist, 200);
}

⚠ Common Pitfalls

  • Integer overflow when stacking multiple 50% resistances
  • Allowing negative resistance to heal the target
  • Applying resistance after damage bonuses from equipment, creating exploitable double-dipping
4

Design Text-Only Feedback Chains

Create message tables in const.c that dispatch different strings based on resistance outcome (full resist, partial resist, absorb, reflect). Use act() with target parameters TO_VICT, TO_CHAR, and TO_ROOM to ensure all observers receive appropriate information without revealing exact resistance percentages to attackers.

const.c
struct spell_message {
    char *to_vict_full_resist;
    char *to_char_full_resist;
    char *to_room_full_resist;
    /* ... partial, absorb variants ... */
};

void dispatch_spell_message(SPELL_DATA *spell, CHAR_DATA *ch, CHAR_DATA *victim, int resist) {
    if (resist == 0) {
        act(spell->msg_full_resist_vict, victim, NULL, ch, TO_CHAR);
        act(spell->msg_full_resist_char, ch, NULL, victim, TO_CHAR);
        act(spell->msg_full_resist_room, ch, NULL, victim, TO_ROOM);
    }
    /* ... other branches ... */
}

⚠ Common Pitfalls

  • Using the same message for 1% damage and 99% damage, removing tactical feedback
  • Leaking exact resistance values in messages ('Your 50% fire resist blocked...')
  • Forgetting TO_ROOM messages, breaking immersion for observers
5

Instrument Combat Logging

Wrap the damage() function with conditional logging that outputs CSV lines to a dedicated combat log file. Include timestamps, attacker name, victim name, spell number, raw damage, resistance percentage applied, and final damage. This enables grep-based analysis of balance without attaching debuggers to live servers.

fight.c
void log_combat_event(CHAR_DATA *ch, CHAR_DATA *victim, int sn, int raw, int resist, int final) {
    FILE *fp;
    
    if ((fp = fopen(COMBAT_LOG, "a")) != NULL) {
        fprintf(fp, "%ld,%s,%s,%d,%d,%d,%d\n",
            current_time,
            ch->name,
            victim->name,
            sn,
            raw,
            resist,
            final);
        fclose(fp);
    }
}

⚠ Common Pitfalls

  • Opening/closing file handle on every damage call causing I/O bottleneck
  • Not rotating logs, filling disk during load testing
  • Logging sensitive player names in plaintext on production servers
6

Validate with Spreadsheet Models

Export the skill_table to CSV using a temporary code snippet in nanny.c or a standalone utility. Import into a spreadsheet alongside expected HP pools per level. Verify that mana efficiency (damage per mana) and time-to-kill against standard mobs matches design targets at levels 1, 25, and 50. Adjust base damage values in tables.c before removing the export code.

tables.c
/* Temporary export function - call from nanny or do_function */
void export_skill_table() {
    FILE *fp = fopen("skill_export.csv", "w");
    int i;
    
    fprintf(fp, "name,mana,min_level,dam_type,base_damage\n");
    for (i = 0; i < MAX_SKILL; i++) {
        if (skill_table[i].name == NULL) break;
        fprintf(fp, "%s,%d,%d,%d,%d\n",
            skill_table[i].name,
            skill_table[i].min_mana,
            skill_table[i].skill_level[0], /* mage level */
            skill_table[i].damage ? skill_table[i].damage->type : -1,
            skill_table[i].damage ? skill_table[i].damage->base : 0);
    }
    fclose(fp);
}

⚠ Common Pitfalls

  • Exporting pointer addresses instead of values, creating garbage CSV
  • Forgetting to remove the export code, creating security risk
  • Using only linear regression when MUD power curves are exponential
7

Update Area File Parser

Modify fread_obj or load_mobiles in db.c if adding reagent drops, or create a new #SPELL section parser if extending area file syntax. Ensure backwards compatibility by checking version headers or using optional fields with strtok NULL checks. Document the new syntax in builder docs using exact examples from your own area files.

db.c
/* In db.c, inside area file parsing */
if (!str_cmp(word, "SPELLMOD")) {
    int sn = fread_number(fp);
    int reagent_vnum = fread_number(fp);
    int count = fread_number(fp);
    
    /* Validate sn bounds to prevent crash */
    if (sn < 0 || sn >= MAX_SKILL) {
        bug("Invalid sn in SPELLMOD", 0);
        fread_to_eol(fp);
    } else {
        skill_table[sn].reagent_vnum = reagent_vnum;
        skill_table[sn].reagent_count = count;
    }
    fMatch = TRUE;
}

⚠ Common Pitfalls

  • Changing delimiter order in existing sections, breaking legacy areas
  • Not validating vnum ranges, allowing crashes from malformed area files
  • Failing to initialize new fields to zero for areas that omit the optional section

What you built

After completing these steps, your MUD will support multi-dimensional spellcasting decisions where players manage inventories, exploit resistances, and receive clear textual feedback on complex interactions. Monitor the combat logs for unexpected damage spikes when resistance modifiers stack, and iterate on reagent rarity to prevent economic inflation. The architecture now supports future extensions such as spell combos or environmental modifiers without further core structural changes.