#global feed
also: ##The Cohost Global Feed, #The Cohost Global Feed, ###The Cohost Global Feed, #Global Cohost Feed, #The Global Cohost Feed, #Cohost Global Feed
what?
traits are an extremely powerful design idea. i recently played pentiment, a game which completely replaces traditional rpg stats with character traits to fantastic effect.
i'm also a big fan of crusader kings, a game where characters have both six d&d style attributes as well as an array of traits that affect their stats and relationships. ck3 has traits in several categories: there are congenital traits, like "club-footed" or "bleeder" that characters are born with and can't be changed. there are personality traits, which characters mostly gain in early childhood but can change with infrequent random events. there are traits like "adulterer" and "dynastic kinslayer" that you get based on actions you take. and there are traits like "ill" or "pneumonia" that represent diseases your character acquires through a random event and will either lose later or die.
i've also played a lot of the sims, and since the sims 3, characters in the sims have had personality traits and moodlets. while they're represented separately, traits and moodlets are conceptually very similar with the exception that moodlets are transient and traits are permanent. traits are set at character creation and might give a character access to special actions, or might influence what kinds of moodlets they can get. moodlets appear when your sim sees or experiences something that can affect their mood: seeing a beautiful painting makes them get a moodlet that makes them happier, seeing garbage in their house gives them a moodlet that makes them less happy.
both crusader kings and the sims have both traits and stats, but pentiment got me thinking about how you could represent all of the things those games represent with stats with traits instead. i don't have a clear picture of the benefits of this approach yet, but i find that when you unify systems it often opens the door to complex emergent interactions that are useful when making narrative games.
i'm building this project using FNA and MoonTools.ECS. i can't go into too much detail, but the core idea of ECS is that you have three kinds of things: Entities, Components, and Systems. an Entity is an integer ID associated with a list of components. a Component is a store of data. Systems are the logic, they can filter entities by what components they have attached, add and remove components from entities, and modify the data stored in entity components.
MoonTools.ECS adds two things to this basic recipe: Messages and Relations. Messages are as far as I know unique to MoonTools: they're basically pubsub messages for ECS. systems can send messages that can be read by other systems. Messages are like components, they store only data.
Relations are a common feature of ECS frameworks, and they're like a Component that is attached to two entities, describing their relationship. Relations are directional in MoonTools.ECS, which will be very useful for us.
when i started building this system, i quickly hated the code i was writing. it was very repetitive: code for each trait was very similar, and it was tightly coupled across a number of files and systems: adding a new trait required adding code in three or four different places and all of it would have to be changed if i wanted to add new traits or change existing ones.
my dream was to describe traits declaratively. to have some kind of metadata file that the game could read that specified what the traits do and let the game work out how to do that. this turned out to be both more and less complicated than i thought.
how?
let's trace our journey starting at the Source of Truth: the metadata. trait information is stored in the balls a json file. let's take a look at the traits which describe hunger:
"HungryLevel1": {
"DisplayName": "Peckish",
"MoodEffect": -2,
"Need": {
"Name": "Hungry",
"NextLevel": "HungryLevel2"
}
},
"HungryLevel2": {
"DisplayName": "Hungry",
"MoodEffect": -4,
"Need": {
"Name": "Hungry",
"PreviousLevel": "HungryLevel1",
"NextLevel": "HungryLevel3"
}
},
"HungryLevel3": {
"DisplayName": "Famished",
"MoodEffect": -16,
"Need": {
"Name": "Hungry",
"PreviousLevel": "HungryLevel2",
"NextLevel": "HungryLevel4"
}
},
"HungryLevel4": {
"DisplayName": "Starving!",
"HealthEffect": -2,
"MoodEffect": -32,
"Need": {
"Name": "Hungry",
"PreviousLevel": "HungryLevel3"
}
},
each trait has an ID and a display name. the ID will become the name of the C# type representing this trait, eg "HungryLevel1." the display name is the name that gets shown to the player.
traits can also have direct effects. there are only two stats in the game: mood and health.
finally, these traits represent needs. we need to specify which need group they're a part of, as well as the order in which the traits go, from least severe to most severe need. rather than specifying the levels explicitly with numbers, i use a linked list style structure where each level points to the one before and after it. this will make things easier later.
now: in my deepest dreams, the game would read from this file at runtime and generate the types and components needed at runtime. this is not the world we live in, unfortunately. that is possible, probably, but it would require some deeply cursed reflection bullshit that i do not have time for. instead, we have to do code generation.
i generate a C# file called Traits.cs from 127-line python script. the file itself is very repetitive and straightforward, the perfect candidate for code generation. lets break it down:
traits_cs.write(
"""using MoonTools.ECS;
using ExpandingUniverse.Data;
namespace ExpandingUniverse.Components.Traits
{
"""
)
first, after the basic python file loading stuff, i write the basic top of the header: imports and the namespace.
needs = []
for name, data in traits.items():
traits_cs.write(
f"\tpublic readonly record struct {name}();\n"
)
if "Need" in data:
if data["Need"]["Name"] not in needs:
needs.append(data["Need"]["Name"])
next, i create the component structs for the traits. traits contains the deserialized json data from the json file. components in MoonTools.ECS are just structs, they have no special properties and don't need to inherit from anything. thanks to the C#10 record structs, this can be a one-liner declaration for each need. record structs can also net a massive performance boost in many circumstances, which is nice.
for need in needs:
traits_cs.write(
f"\tpublic readonly record struct {need}Need();\n"
)
we also need structs for all the need groups, which is why i stored the need names in that list back there.
traits_cs.write(
"""\tpublic static class TraitManager
{
public static void InitializeTraits(World world)
{
"""
)
now we create a class called TraitManager that is going to store the method we'll use for initializing all the trait stuff when the game launches.
let me back up: my first thought was to model the traits as components. a character is an entity and a trait is a component. this doesn't quite work. we want to be able to list all the traits a character as and otherwise iterate over them. you can't iterate over all the components attached to an entity in MoonTools.ECS (at least not in release mode) because it has performance implications: creating the iterators means boxing the components.
you can however iterate over all the relationships a component has. so we instead need to model traits as entities and "having a trait" is a kind of relation between the character entity and the trait entity.
we also want to model the need groups as entities to which the need traits belong using entity relations.
for need in needs:
traits_cs.write(
f"""
var {need}NeedEntity = world.CreateEntity();
world.Set({need}NeedEntity, new {need}Need());
world.Set({need}NeedEntity, new Need(TextStorage.GetID(\"{need}\")));
"""
)
first, we create all the need group entities. these have two components: the need component we created earlier, and a generic "Need" component that indicates this is a need. the Need component also stores the name of the need group for debugging purposes which...
oh right. i should explain that.
so, components in MoonTools.ECS have to be unmanaged. this is a constraint you can put on generic parameters in C#. unmanaged types include all value types, as well as any struct whose members are all unmanaged. "unmanaged" means that the garbage collector doesn't touch them, they're allocated on the stack and don't need GC cycles to be freed. this restriction results in massive performance improvements. however...
strings are by their very nature managed types. strings are allocated on the heap. so you can't store strings in components. instead, you basically have to... reinvent the concept of a pointer from first principles. TextStorage is a class that has a dictionary mapping integers to strings. GetID will return the integer that maps to a string, or generate a new one if the string hasn't been stored yet. there's another method, GetString, which returns the text given an integer. this is cumbersome.
on the other hand, some kind of structure like this is basically necessary if you want your game to be localizable. and it allows you to, in many cases, substitute int compares for string compares, which is a small performance boost on top of the huge performance boost you get for this minor inconvenience.
for name, data in traits.items():
traits_cs.write(
f"""
var {name}Entity = world.CreateEntity();
world.Set({name}Entity, new {name}());
"""
)
if "DisplayName" in data:
display_name = data["DisplayName"]
traits_cs.write(
f"world.Set({name}Entity, new DisplayName(TextStorage.GetID(\"{display_name}\")));\n"
)
if "MoodEffect" in data:
mood_effect = data["MoodEffect"]
traits_cs.write(
f"world.Set({name}Entity, new MoodEffect({mood_effect}));\n"
)
if "HealthEffect" in data:
health_effect = data["MoodEffect"]
traits_cs.write(
f"world.Set({name}Entity, new HealthEffect({health_effect}));\n"
)
if "Duration" in data:
duration_min = data["Duration"]["Min"]
duration_max = data["Duration"]["Max"]
traits_cs.write(
f"world.Set({name}Entity, new Duration({duration_min}, {duration_max}));\n"
)
if "Need" in data:
need_name = data["Need"]["Name"]
traits_cs.write(
f"world.Relate({name}Entity, {need_name}NeedEntity, new IsNeed());\n"
)
traits_cs.write("\n")
now we build the trait entities themselves! each field in their JSON object becomes a component. these components are specified in another file, TraitFields.cs:
namespace ExpandingUniverse.Components.Traits
{
//RELATIONS
public readonly record struct HasTrait();
public readonly record struct IsNeed();
public readonly record struct IsFirstLevelNeed();
public readonly record struct NextLevel();
public readonly record struct PreviousLevel();
// COMPONENTS
public readonly record struct Need(int Name);
public readonly record struct DisplayName(int Name);
public readonly record struct MoodEffect(int Effect);
public readonly record struct HealthEffect(int Effect);
public readonly record struct Duration(int Min, int Max);
}
each of these compnents can have its own system associated with it, and filter to just the traits that they need to deal with.
for name, data in traits.items():
if "Need" in data:
need_name = data["Need"]["Name"]
if "NextLevel" in data["Need"]:
next_level_name = data["Need"]["NextLevel"]
traits_cs.write(
f"world.Relate({name}Entity, {next_level_name}Entity, new NextLevel());\n"
)
if "PreviousLevel" in data["Need"]:
prev_level_name = data["Need"]["PreviousLevel"]
traits_cs.write(
f"world.Relate({name}Entity, {next_level_name}Entity, new PreviousLevel());\n"
)
else:
traits_cs.write(
f"world.Relate({name}Entity, {need_name}NeedEntity, new IsFirstLevelNeed());"
)
finally we have to do a second pass to set up the entity relationships. every need trait needs to have NextLevel and PreviousLevel relations with its fellow traits, and we add a special component to the trait that has no PreviousLevel to make it easy to figure out where the head of this linked list is.
traits_cs.write(
"""}
}
}
"""
)
traits_cs.close()
os.system("dotnet format ExpandingUniverse.csproj --include Components/Traits.cs")
then we close everything out and format the file.
now is where the magic happens: the NeedsSystem. this is where we handle adjusting people's needs without ever having to actually specify the needs themselves. i'll focus just on the important bit, in the Update() function:
foreach (var denizenEntity in DenizenFilter.Entities)
{
foreach (var needEntity in NeedsFilter.Entities)
{
var levels = InRelations<IsNeed>(needEntity);
var hasTrait = false;
foreach (var (level, need) in levels)
{
if (Related<HasTrait>(denizenEntity, level))
{
hasTrait = true;
if (HasOutRelation<NextLevel>(level))
{
if (Random.Value <= NeedChance)
{
var (nextLevelEntity, nextLevel) = OutRelationSingleton<NextLevel>(level);
Unrelate<HasTrait>(denizenEntity, level);
Relate<HasTrait>(denizenEntity, nextLevelEntity, new HasTrait());
break;
}
}
}
}
if (!hasTrait)
{
var (firstLevelEntity, firstLevel) = InRelationSingleton<IsFirstLevelNeed>(needEntity);
if (Random.Value < NeedChance)
{
Relate<HasTrait>(denizenEntity, firstLevelEntity, new HasTrait());
}
}
}
}
ok, so first we iterate over every denizen using a filter, and then we iterate over every need group using another filter. we get every trait in this need group with InRelations, which gives us a list of every entity that has a relationship that goes FROM that entity TO our need entity.
next, we go through every level. if we already have a level of this need, there's some chance that it will go up to the next one. we do this by breaking the existing relationship and forming a new one with the next level up, which we get via OutRelationSingleton, a handy method that lets us get the entity this entity has a relationship with if there's only one entity we have this specific type of relationship with (god that is the worst sentence i've ever written in my whole life).
finally, if we didn't have any trait in this need group, we find the first level of the need group and there's some chance we might give the trait to the denizen.
conclusion
what does this all look like?
...not much. yet! this is really exciting to me. i'm super pleased with how it turned out. it's going to reduce a lot of tedium and room for error and allow a lot of really cool flexibility. i'm planning to do similar things to this for other aspects of the game. there's a lot of power in the declarative metadata -> ecs approach, i think. i've already extended this to support traits that disappear after a period of time, including having a random duration between a minimum and a maximum. i'll keep posting updates as i work more on this project.
if you made it this far, maybe support me on patreon or toss me a few bucks on ko-fi?
I would like luxurious Parisian hotels to leave me alone while I try to listen to Johanna Newsom songs on the YouTube.
