Skip to content

Instantly share code, notes, and snippets.

@becked
Last active December 6, 2025 23:25
Show Gist options
  • Select an option

  • Save becked/aef8896cf44914e083d9b6aca4f78274 to your computer and use it in GitHub Desktop.

Select an option

Save becked/aef8896cf44914e083d9b6aca4f78274 to your computer and use it in GitHub Desktop.

Bug Report: Courtier Mirroring in Multiplayer

Summary

Family seat courtiers (Traders/Merchants, Patrons/Ministers) are cloned between players who found their family seat on the same turn. Both players receive courtiers with identical gender, name, ratings, archetype, and adjective traits.

Severity

Medium - Affects multiplayer balance and immersion when multiple players select the same family class.

Affected Versions

All versions using the current addCourtier implementation.

Root Cause

The addCourtier function in Player.cs seeds its RNG using the game turn number instead of a unique identifier:

// Player.cs:21355-21374
protected virtual Character addCourtier(CourtierType eType, GenderType eGender, FamilyType eFamily, Player pFromPlayer = null)
{
    RandomStruct pRandom = new RandomStruct(game().getSeedForId(game().getTurn()));
    //                                                          ^^^^^^^^^^^^^^^
    //                                     BUG: Uses turn number, not character ID

    if (eType == CourtierType.NONE)
    {
        eType = (CourtierType)(pRandom.Next((int)infos().courtiersNum()));
    }

    Character pCharacter = game().createNewCharacter(
        infos().Globals.COURTIER_AGE,
        getPlayer(),
        eGender,
        NameType.NONE,
        eFamily,
        TribeType.NONE,
        ((pFromPlayer != null) ? pFromPlayer.getNation() : NationType.NONE),
        null,
        pRandom.NextSeed()  // Seed derived from turn-based RNG
    );

    pCharacter.setCourtier(eType);
    pCharacter.generateRatingsCourtier(eType);
    // ...
}

Compare to other character creation functions that correctly use unique identifiers:

// Player.cs:15920 - createAdult
RandomStruct random = new RandomStruct(game().getSeedForId(game().getNextCharacterID()));

// Player.cs:16816 - character spawning
RandomStruct random = new RandomStruct(game().getSeedForId(game().getNextCharacterID()));

// Game.cs:10717 - child creation
RandomStruct random = new RandomStruct(getSeedForId(getNextCharacterID()));

Technical Analysis

Seed Propagation Chain

When addCourtier is called on Turn 0 for both players:

  1. Both players call getSeedForId(game().getTurn()) = getSeedForId(0)
  2. Both receive identical seed S0
  3. Both call pRandom.NextSeed() producing identical seed S1
  4. Both pass S1 to createNewCharacter()

Inside createNewCharacter -> createCharacterNext:

  1. RandomStruct random = new RandomStruct(S1) - same for both
  2. Gender determined by random.Next() - same result
  3. Name determined by randomFirstName(..., random.NextSeed()) - same result
  4. Character initialized with random.NextSeed() - same seed

Inside generateRatingsCourtier:

  1. Ratings via randomNext() - same results
  2. Archetype via nextRandomSeed() - same result
  3. Adjective via nextRandomSeed() - same result

Result

Both courtiers are functionally identical clones, differing only in:

  • Character ID (assigned via getNextCharacterID() in createCharacter)
  • Player ownership
  • Family assignment (if different families selected)

Steps to Reproduce

  1. Start a new multiplayer or hotseat game with 2 players
  2. Player 1 selects a nation with Traders as their first family
  3. Player 2 selects a nation with Traders as their first family
  4. Both players found their capital cities (family seats) on Turn 0
  5. Compare the Merchant courtiers granted to each player

Expected: Independent, randomly-generated courtiers Actual: Identical courtiers with same name, gender, ratings, and traits

Affected Families

Family Class Courtier Type Bonus
Traders Merchant BONUS_FAMILYCLASS_TRADERS_SEAT
Patrons Minister BONUS_FAMILYCLASS_PATRONS_SEAT

Suggested Fix

Replace the turn-based seed with a character-ID-based seed, consistent with other character creation code:

protected virtual Character addCourtier(CourtierType eType, GenderType eGender, FamilyType eFamily, Player pFromPlayer = null)
{
    // FIX: Use getNextCharacterID() instead of getTurn()
    RandomStruct pRandom = new RandomStruct(game().getSeedForId(game().getNextCharacterID()));

    // ... rest of function unchanged
}

This ensures each courtier receives a unique seed regardless of when they are created.

Alternative Fix

If the original intent was to make courtier creation deterministic based on game state (for replay consistency), an alternative approach would be to incorporate additional unique factors:

// Option: Combine turn with player ID for uniqueness while maintaining determinism
RandomStruct pRandom = new RandomStruct(game().getSeedForId(game().getTurn() * 1000 + (int)getPlayer()));

However, the standard approach using getNextCharacterID() is recommended for consistency with the rest of the codebase.

Related Code Locations

  • Source/Base/Game/GameCore/Player.cs:21355-21374 - addCourtier() (bug location)
  • Source/Base/Game/GameCore/Player.cs:15920 - createAdult() (correct pattern)
  • Source/Base/Game/GameCore/Player.cs:16816 - character spawning (correct pattern)
  • Source/Base/Game/GameCore/Game.cs:10309-10338 - createCharacterNext()
  • Source/Base/Game/GameCore/Game.cs:10340-10343 - createNewCharacter()
  • Source/Base/Game/GameCore/Character.cs:8805-8841 - generateRatingsCourtier()
  • Source/Base/Game/GameCore/PlayerBonus.cs:5861-5870 - bonus processing that calls addCourtier()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment