The Peacenet: How we completely redesigned the core and made the game more fluent

Posted by Administrator.

Remember how I announced that delay on the release of Peacenet's alpha in response to a few critical bugs that I didn't catch in testing? Well this is the article where I talk about just why that delay was important, what we've done to counter-act those bugs in the future, and how we made the game far more playable and responsible.

In this article I'm going to talk about many things - sneaky gamedev tricks in the UI that hide things that are going on, some strategies in the backend that make things a lot faster, and some new features that are coming in the game. So, saddle up, my sweet readers, because this is gonna be a lot.

Sneaky gamedev tricks

One of the big issues with Old Peacenet and Unreal Peacenet Milestone 1 was the world geerator. It really got in the way. It was a single asynchronous function that took 600 lines of code and was notoriously slow. Not only was it slow, but it had many gamebreaking bugs that weren't showing up in the Unreal editor. Something needed to be done about it.

I actually discussed this with ImmutableLambda from the Unreal Slackers Discord server, and I mentioned a lot of these issues to him. He made a very important point.

Discord DM with ImmutableLamba

Good point! There really is no need to do asynchronous world generation especially since I hate multi-threading. Who cares if my world generator takes a minute to finish? I'm a game developer. There's a very good solution for it. Hide it behind a black screen! And that's just what I did.

The new feature

When Peacenet first creates a world, it will fade from the main menu to black after prompting you for your Agent Identity name. During the darkness, it is generating only the barebones information it needs for the world - more on that later. It may be a tad slow in the Unreal Editor, however, it is almost instant in a standalone optimized/shipping build of the game.

The side effect is that you won't know what the world generator is doing, but you don't need to. The only person who does would be a developer, and the game logs everything it generates to the debugger. If that black screen stays on for more than a second, you're most likely in a development build or you're trying to load an older save file and the game is getting choked up trying to convert older Peacenet entities to the new system - again, more on that later.

This darkness-during-world-gen feature also allows for more immersive story-telling too, actually. Since it's near instant, and pure black, I can just throw up a Terminal when it's done and start to intro the game - just like Peacenet's MonoGame version of ye olde days.

The New World Generator

If we were using the old world gen code in the development environment, we'd be wanting to slit our throats with that black screen I just talked about. Thankfully, the next thing I did was completely redo the entire game's core codebase - the save system, the world state actor, and that god damn world generator.

I had a few ideas in mind when I did this rework.

  • It has to be split up. No monolithic functions that take up half of a 1000-line C++ source file. Each action the core takes is split off into its own independent function. This allows the game to call any part of the core as needed (mostly) without relying on other things being done first.

  • Nothing should be done until it has to be. If an NPC is only just spawning in now, there is NO NEED to generate a full computer for it including services, filesystem, loots, etc. All it needs is its base stats - enough info to generate those things later, when they're needed. This results in a smaller, easier-to-save-and-load save file, and it makes many world generation actions much faster.

  • The core should adapt to changes without the player knowing. This means that, if I change some of the procedural generation algorithms that, for example, generate loots for an NPC computer, you shouldn't have to restart with a new save file. This was a problem in the older world generator, and now the game will just use the new algorithms for new entities in the game and not touch old ones.

So how'd you accomplish it?

Well, that's a more fun thing to talk about! I accomplished it by splitting the various actions of the world generator into different passes, which are further split into individual actions. Passes are groups of actions that accomplish the task of generating a world. Each action can be invoked at any time during gameplay, but passes will use those actions in a more algorithmic fashion and can themselves be treated as actions.

An example of a pass would be the Player Generation pass, which simply takes in your Agent Identity name and a set of Game Rules from Blueprint land and creates a save file with your default stats and some necessary settings for later passes. Here's the code:

void UPeacenetGameInstance::CreateWorld(FString InCharacterName, UPeacenetGameTypeAsset* InGameType)
{
    check(InGameType);

    if(APeacenetWorldStateActor::HasExistingOS())
    {
        UGameplayStatics::DeleteGameInSlot("PeacegateOS", 0);
    }

    // Create a new save game.
    UPeacenetSaveGame* SaveGame = NewObject<UPeacenetSaveGame>();

    // The save file needs to keep a record of the game type.
    SaveGame->GameTypeName = InGameType->Name;

    // Setting this value causes the game to generate NPCs and other procedural
    // stuff when we get to spinning up the Peacenet world state actor later on.
    SaveGame->IsNewGame = true;

    // Now we parse the player name so we have a username and hostname.
    FString Username;
    FString Hostname;
    UCommonUtils::ParseCharacterName(InCharacterName, Username, Hostname);

    // We want to create a new computer for the player.
    FComputer PlayerComputer;

    // Assign the computer's metadata values.
    PlayerComputer.ID = 0; // This is what the game identifies the computer as internally. IP addresses, domains, etc. map to these IDs.
    PlayerComputer.OwnerType = EComputerOwnerType::Player; // This determines how the procedural generation system interacts with this entity later on.

    // Format the computer's filesystem.
    UFileUtilities::FormatFilesystem(PlayerComputer.Filesystem);

    // Create a new folder called "etc".
    FFolder EtcFolder;
    EtcFolder.FolderID = 1;
    EtcFolder.FolderName = "etc";
    EtcFolder.ParentID = 0;
    EtcFolder.IsReadOnly = false;

    // Add a file called "hostname" inside this folder.
    FFile HostnameFile;
    HostnameFile.FileName = "hostname";
    HostnameFile.FileContent = FBase64::Encode(Hostname);

    // Write the file to the etc folder.
    EtcFolder.Files.Add(HostnameFile);

    // Write the folder to the computer FS.
    PlayerComputer.Filesystem.Add(EtcFolder);

    // How's that for manual file I/O? We just set the computer's hostname...
    // BEFORE PEACEGATE WAS ACTIVATED.

    // Now we create the root user and root folder.
    // We do the same for the player user.
    FUser RootUser;
    RootUser.ID = 0;
    RootUser.Username = "root";
    RootUser.Password = "";
    RootUser.Domain = EUserDomain::Administrator;

    FUser PlayerUser;
    PlayerUser.ID = 1;
    PlayerUser.Username = Username;
    PlayerUser.Password = "";
    PlayerUser.Domain = EUserDomain::PowerUser;

    // Now Peacegate can identify as these users.
    PlayerComputer.Users.Add(RootUser);
    PlayerComputer.Users.Add(PlayerUser);

    // But they still need home directories.
    // Create a new folder called "etc".
    FFolder RootFolder;
    RootFolder.FolderID = 2;
    RootFolder.FolderName = "root";
    RootFolder.ParentID = 0;
    RootFolder.IsReadOnly = false;

    FFolder HomeFolder;
    HomeFolder.FolderID = 3;
    HomeFolder.FolderName = "home";
    HomeFolder.ParentID = 0;
    HomeFolder.IsReadOnly = false;

    FFolder UserFolder;
    UserFolder.FolderID = 4;
    UserFolder.FolderName = Username;
    UserFolder.ParentID = 3;
    UserFolder.IsReadOnly = false;
    HomeFolder.SubFolders.Add(UserFolder.FolderID);

    // Write the three new folders to the disk.
    PlayerComputer.Filesystem.Add(RootFolder);
    PlayerComputer.Filesystem.Add(HomeFolder);
    PlayerComputer.Filesystem.Add(UserFolder);

    // Wallpaper needs to be nullptr to prevent a crash.
    PlayerComputer.CurrentWallpaper = nullptr;

    // Next thing we need to do is assign these folder IDs as sub folders to the root.
    PlayerComputer.Filesystem[0].SubFolders.Add(EtcFolder.FolderID);
    PlayerComputer.Filesystem[0].SubFolders.Add(RootFolder.FolderID);
    PlayerComputer.Filesystem[0].SubFolders.Add(HomeFolder.FolderID);

    // Now, we add the computer to the save file.
    SaveGame->Computers.Add(PlayerComputer);

    // Set the player UID to that of the non-root user on that computer.
    // This makes Peacenet auto-login to this user account
    // when the desktop loads.
    SaveGame->PlayerUserID = PlayerUser.ID;

    // Now we create a Peacenet Identity.
    FPeacenetIdentity PlayerIdentity;

    // Set it up as a player identity so procedural generation doesn't touch it.
    PlayerIdentity.ID = 0;
    PlayerIdentity.CharacterType = EIdentityType::Player;

    // The player identity needs to own their computer for the
    // game to auto-possess it.
    PlayerIdentity.ComputerID = PlayerComputer.ID;

    // Set the name of the player.
    PlayerIdentity.CharacterName = InCharacterName;

    // Set default skill and reputation values.
    PlayerIdentity.Skill = 0;
    PlayerIdentity.Reputation = 0.f;

    // The game type's rules tells us what country to spawn this character in.
    PlayerIdentity.Country = InGameType->GameRules.SpawnCountry;

    // Add the character to the save file.
    SaveGame->Characters.Add(PlayerIdentity);

    // Set the player's location on the map to the origin.
    SaveGame->SetEntityPosition(PlayerIdentity.ID, FVector2D(0.f, 0.f));

    // This makes the game auto-possess the character we just created.
    SaveGame->PlayerCharacterID = PlayerIdentity.ID;

    // Player should know their own existence.
    SaveGame->PlayerDiscoveredNodes.Add(PlayerIdentity.ID);

    // Save the game.
    UGameplayStatics::SaveGameToSlot(SaveGame, "PeacegateOS", 0);
}

Congrats - that's the first ever bit of code that executes when the screen goes black. What does it do? It may seem like a lot, but I'll break it down.

  1. It checks if there's an existing save file and, if so, it deletes it to make space for the new one.
  2. It creates a new save file, assigning the specified Game Type to it and setting a value that tells a later world generator pass that the save file is brand new.
  3. Then it parses your Agent Identity name into a Peacegate OS username and a hostname.
  4. After that, it creates a new Computer Entity - your computer, assigning it to Entity ID 0, and setting it as a Player Entity. This tells the world generator not to do any procedural generation whatsoever on that computer.
  5. It sets up a Peacegate OS Filesystem for that Computer Entity, creating the folders /etc, /home, /root, and /home/YourAgentUsernameHere.
  6. It writes your Hostname into /etc/hostname.
  7. Then it creates a Peacegate User Identity with ID 0, Username root, Domain Administrator and no password - so that the in-game sudo command that'll come later on can work.
  8. Then it does that again, only this time the user ID is 1, and is for your Agent User. This is who you actually log in as in-game.
  9. Then it creates a Peacenet Identity for you, assigning your default stats and your Agent Identity Name as the identity's name.
  10. The Computer then gets assigned to the Identity, so that the game knows which computer is yours.
  11. Then your Agent User ID and Agent Identity ID are stored in the save file as variables, so the game knows which Identity is yours.
  12. Then the actual Identity and Computer entities are stored in the save file, some default values are set, and the save file is saved!

All that happens in just a few milliseconds, and it's all you need to at least enter Peacegate OS! After that, it does a similar thing for a thousand NPCs (WAY more than old Peacenet could do), placing them in random countries, giving them random stats, random names, etc. All that, again, in just a few more milliseconds per NPC (in dev builds). Yes, that adds up to over a second, but that's really not too bad. Compared to the 10 seconds it'd take on a 12-core Xeon processor t get through the old world gen code.

So that's it for now.

I'm getting really tired and I'll be honest my stomach is killing me right now. As much as I'd love to continue writing about this, I'll save it for another day when I feel better. So look out for part 2 in the future, I'll be going over some of the ways we accomplish fast procedural NPC generation. Thanks for reading. :)