Moddable Character Creation

Making Of / 24 January 2025

Background

I love modding games nearly as much as I love playing them. My modlist for The Elder Scrolls games have grown to legendary lengths and my friends have made a joke of the whole thing - "Just play a different game," they say. But what makes me love modding games is the potential for player creativity. By modding a game, the player gains control over their experience with the game becoming a vehicle to make that experience possible.

One game I recently became invested in is Troika Games' Arcanum: Of Steamworks and Magick Obscura. The game is incredibly interesting, but was rushed to release and is missing some key features. One such missing feature is the ability to play through the game as a female halfling, gnome, or dwarf. A fan-made patch was later released that added premade characters to fulfill these character archetypes, but this did not include the ability to change the character's portraits. I wanted to use a custom portrait for my character, so I got to work digging into the files to do it myself.

To modify the portraits used by the game, the player must access Arcanum's "data" directory. The "Portraits" directory can be modified to include photos which will show up in the game if they override the game's default portraits. These photos must be of particular dimensions and of the .bmp format, but otherwise, give the player more control over their character creation experience.

The System

While working on my own video game project, I decided that I would like to provide the player with more control over their character creation experience, and that I would allow this by following the process I had used for modding Arcanum. By accessing the games "Data" directory, all images can be modified to change the portraits used by the game for the default races. The player can also add completely new photos, and those will be added to the list of available portraits for the races they choose to modify.




Using this same process, the player can choose to define completely new races and backgrounds, along with their own custom stat bonuses.

Implementation

The character creation screen is opened when the CharCreator class is instantiated. When the class is created, the first that that happens is a defaults check. If the data directory is not made, it creates it. if any of the default races or backgrounds are not present, or if any of their contents are absent, they are populated.

void ACharCreator::InitializeDataFiles()
{
    // Initialize Data directory.


    FString launchDir = FPaths::LaunchDir();
    dataDir = launchDir / "Data";
    raceDir = dataDir / "Races";
    rStatsDir = raceDir / "Stats";
    rPortraitsDir = raceDir / "Portraits";
    classDir = dataDir / "Classes";
    classStatsDir = classDir / "Stats";


    CreateDirectory(dataDir);
    CreateDirectory(raceDir);
    CreateDirectory(classDir);
    CreateDirectory(rStatsDir);
    CreateDirectory(rPortraitsDir);
    CreateDirectory(classStatsDir);


    // Initialize race portrait directories.


    rAktiin = rPortraitsDir / "Aktiin";
    rAradiin = rPortraitsDir / "Aradiin";
    rLunath = rPortraitsDir / "Lunath";
    rSiron = rPortraitsDir / "Siron";
    rZerath = rPortraitsDir / "Zerath";


    CreateDirectory(rAktiin);
    CreateDirectory(rAradiin);
    CreateDirectory(rLunath);
    CreateDirectory(rSiron);
    CreateDirectory(rZerath);
}

void ACharCreator::CreateDirectory(FString dirName)
{
    if (!FPaths::DirectoryExists(dirName))                                // Check if directory does not exist.
    {
        if (IFileManager::Get().MakeDirectory(*dirName, true))            // Make new directory.
        {
            UE_LOG(LogTemp, Display, TEXT("%s directory created successfully."), *dirName);
        }
        else
        {
            UE_LOG(LogTemp, Error, TEXT("Could not create %s directory."), *dirName);
        }
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("%s directory already exists."), *dirName);
    }
}

After defaults are checked and validated, the contents of the races and background directories are read. Structs are created for the races and backgrounds which include their name, description, and stat bonuses. These newly created structs are then added to a dictionary with the keys being the race/class name in the type FName.

void ACharCreator::ReadRaces()
{
    // Get list of race file names and stat file names.

    TArray rNames{};
    IFileManager::Get().FindFiles(rNames, *raceDir, TEXT("*.txt"));
    TArray rStats{};
    IFileManager::Get().FindFiles(rStats, *rStatsDir, TEXT("*.txt"));


    // Remove extension, then add to race names array. Used for UI display purposes.

    raceNames.Reserve(rNames.Num());
    for (FString name : rNames)
    {
        FString newName = name;
        newName.RemoveFromEnd(TEXT(".txt"));
        FName raceName = FName(*newName);
        raceNames.Add(raceName);
    }

    // Create FRace for each race in Data/Races and add to races array.


    raceRegistry.Reserve(rNames.Num())
    for (FString raceFile : rNames)
    {
        bool foundMatch = false;

        for (FString statFile : rStats)
        {
            if (raceFile.Equals(statFile))
            {
                MakeRace(raceFile, statFile);
                UE_LOG(LogTemp, Display, TEXT("Created race %s"), *raceFile);
                foundMatch = true;
                break;
            }
        }

        // If no corresponding stat file is found, give race a default stat block.

        if (!foundMatch)
        {
            UE_LOG(LogTemp, Warning, TEXT("No race and stats match found. Creating default stats for race."));
            MakeRace(raceFile, "default");
        }
    }
}

void ACharCreator::MakeRace(FString raceFile, FString statsFile)
{
    // Create race name and load description.

    FString raceName = raceFile;
    FString raceDesc{};
    raceName.RemoveFromEnd(TEXT(".txt"));
    FFileHelper::LoadFileToString(raceDesc, *(raceDir / raceFile));

    //Check if stat file exists. If true, load. If not, make default stat block.

    FStatBlock raceMod{};

    if (FPaths::FileExists(*(rStatsDir / statsFile)))
    {
        FString stats{};
        FFileHelper::LoadFileToString(stats, *(rStatsDir / statsFile));
        raceMod = MakeStatBlock(stats);
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("%s stats file not found. Adding default stats."), *raceName);
        raceMod = FStatBlock();
    }

    // Make struct and add to registry.

    FRace race = FRace(FName(raceName), FText::FromString(raceDesc), raceMod);
    raceRegistry.Add(race.name, race);
}

// Same process repeated for backgrounds

A demo version of this system can be downloaded here.

A repository with the source code for this system can be found here.