Reactive & Dynamic Dialogue, and a Better Paced Intro
Overview
October was a difficult month. A death in the family and the resulting support and care took priority. I was able to work on the early game pacing issues that were mostly dialogue changes, unlock reordering and play testing.
The good news is that Exofactory is a much more exciting game to get into. Less of a "In a world where you play as a lost AI" cinematic intro and more of a "I'm a lost AI and I need to do things!" vibe.
Given that me editing dialogue and unlock conditions doesn't produce suuuuuper interesting blog content I thought I would take the opportunity to cover Exofactory's reactive, dynamic dialogue system.
It's pretty cool and entirely data driven.
As always if you find this game interesting or want to support my work here a wishlist on Steam is very helpful.
Dialogue System Overview
In Exofactory you play as an AI, who is constantly thinking and self reflecting. It comments on things as the player plays, and game progress is deeply tied to that self reflection.
The tutorial dialogue is simpler, all living in one giant RON file per language. For the tutorial that's fine, it plays from start to finish in a fixed order. After the tutorial though, the dialogue is really just a pile of small unrelated events that can fire in pretty much any order.
Given that I prefer to go with a data oriented approach whenever I can, I went looking for a nicer way to lay this out and ended up stealing an idea from Linux.
Stealing from Linux
On Linux (or most other modern OSes like the BSDs) there are .d directories like /etc/nginx/conf.d/ or /etc/sysctl.d/. Basically, instead of one giant config file you can break it down into many smaller chunks of config. This pattern lets Linux admins break changes into smaller controlled chunks. You don't need to think about nginx worker counts when you are adding a new reverse proxy endpoint.
I am a huge fan of this pattern. You can add or remove events in an independent way that is clean in code and is clean in the git history.
All of the dialogue and associated metadata is self contained and simple.
assets/core/event_data/dialogue/tier1/
discover_iron_ingot.d_group.ron
make_zinc_ingot.d_group.ron
open_furnace_window.d_group.ron
unlock_screws.d_group.ron
...What a d_group File Looks Like
Each file has two parts, the metadata and the dialogue itself. Here is the metadata for when the player mines their first iron ore:
metadata: (
id: "tier1.discover_iron_ingot",
set: Tier1,
watch_keys: [
MemoryGroup(Items),
],
conditions: [
(
t: MemoryAmount,
c: (
value: ItemsIronOre,
comparison: GreaterOrEqual,
threshold: 1,
),
),
],
fire_mode: Once,
before_dialogue: [
(
t: GrantRecipes,
c: (
recipes: [
HandcraftIronIngot,
SmeltIronIngot,
],
),
),
],
after_dialogue: [],
),
Above we can see both when the dialogue fires and what it does. The watch_keys say "only bother looking at me when something about items changes". The conditions (for now the game only supports inclusive conditions) say the iron ore count needs to be at least 1. fire_mode: Once means it never is triggered more than once. The before_dialogue or after_dialogue part is where the unlocks can happen, in this case two iron ingot recipes get granted right before the line plays.
I am not actually thrilled with t: and c: everywhere, but it was the cleanest implementation I could write given how serde handles Enum variant serialization.
The second part is the dialogue. Each line of dialogue has a path to its audio file, the dialogue text itself and additional metadata like audio file length and pre and post audio delay. The game just plays them in order from the file when triggered.
Loading the Files
Here is where the Bevy ecosystem did most of the work for me.
bevy_common_assets lets me specify that any file ending in d_group.ron should be deserialized into a DialogueTriggerAsset struct:
app.add_plugins(RonAssetPlugin::<DialogueTriggerAsset>::new(&[
"d_group.ron",
]))
Then bevy_asset_loader just loads an entire tier / directory of dialogue as a collection:
#[derive(AssetCollection, Resource)]
pub struct Tier1TriggerAssets {
#[asset(path = "core/event_data/dialogue/tier1", collection(typed))]
pub triggers: Vec<Handle<DialogueTriggerAsset>>,
}
And that's pretty much all of the loading code. There is no list of files anywhere. If a file is in that directory and ends in d_group.ron it gets loaded.
Firing the Dialogue
The game tracks what the player has done in a Memories component (buildings, items, building menus, camera switches, and so on) along with Mood aka the emotional state of the AI. I compare those data structs against a copy from the last dialogue trigger check. If, say, the items count sub struct changed, then the triggers watching items get their conditions checked.
Anything that is set to trigger goes into a queue. When no other dialogue is playing the next one gets popped, the before actions run, the lines for the player's language get picked, and it all gets handed off to the subtitle and audio systems.
This works out nicely for multiplayer too. The host just keeps the Mood and Memories updated and the client just reacts to it.
Conclusion
I am glad for an excuse to explain how the dialogue system in Exofactory works. The circumstances are not great but between the faster paced game intro, and the upcoming terrain rework exciting times are ahead.
If you want to play Demo 2 when it comes out, or to support me in general you can wishlist the game here: