Levels, dialogs and quests
In short: Experience is counted from a table, and new levels and skills are handed out one at a time; combat and the map only publish events to a bus, while quests subscribe and are state machines; a dialog is a tree in which conditions hide the answers that do not apply.
Problem
The wolf that does not know there is a quest
So far the hero has killed enemies and that was all. Now the game wants something more to happen: killing the wolf leader should close a quest, killing the Warden should give the victory, entering the second level should move another quest forward, and experience should raise the level. The simplest way is to write it down where the enemy dies:
void OnEnemyKilled(Enemy enemy){ hero.Xp += enemy.Xp; // Combat knows every part of the game that cares when an enemy dies. quests.EnemyKilled(enemy); achievements.EnemyKilled(enemy); // A new listener: one more line here and one more change in combat. stats.Kills++;}Here combat knows about quests, achievements and statistics. A new quest is a new line in combat, and combat already has many reasons to change. Worse: every new listener is added in someone else's code, and every forgotten link is a quest that never finishes.
The fix is to turn the direction of knowledge around. Combat says in one place "the enemy died" and does not care who listens. Whoever wants to know signs up (subscribes). That is an event bus. The lesson builds five things, in the order the game uses them: experience and levels (a curve as a table), a skill tree, an event bus, a quest as a state machine and a dialog as a tree with conditions.
This is the same place once there is a bus. Combat publishes one line and that is all:
void OnEnemyKilled(Enemy enemy){ hero.Xp += enemy.Xp; bus.Publish(new GameEvent("EnemyKilled", enemy.Kind));}The idea
Experience and levels: the curve is a table
How much experience does each level need? A formula (say 100 × level²) is smooth, but it is awkward for tuning a game: you want to say "level 3 comes after about six enemies", not to calculate. So the curve is a table: XP_TABLE[n] is the total experience at which level n is reached. Past the end of the table the curve continues with 150 more per level, so the game does not crash at level 11.
The hero has xp and level. On every kill xp grows, and pendingLevels says how many levels are waiting: how many of the next thresholds the experience has already reached. The new level is a separate step (raiseLevel): more life. The program kills seven enemies from the game, each with its own experience (rat 5, goblin 9, ... the Warden 150), and prints every new level:
class Hero{ public int Level = 1; public int Xp = 0; public int MaxHp = 48;}
record Kill(string Name, int Xp);
// XP_TABLE[n] is the total experience at which level n is reached.int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level){ if (level < xpTable.Length) return xpTable[level]; // Past the end of the table the curve goes on with 150 per level. return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;}
// How many levels are waiting: does the experience reach the next thresholds.int PendingLevels(int xp, int level){ int levels = 0; while (xp >= XpForLevel(level + levels + 1)) levels++; return levels;}
void RaiseLevel(Hero hero){ hero.Level += 1; hero.MaxHp += 9;}Kill[] kills = [new Kill("rat", 5), new Kill("goblin", 9), new Kill("shaman", 12), new Kill("wolf", 14), new Kill("cultist", 20), new Kill("golem", 32), new Kill("Warden", 150)];
var hero = new Hero();foreach (var kill in kills){ hero.Xp += kill.Xp; Console.WriteLine(kill.Name + " +" + kill.Xp + ": xp " + hero.Xp); int raise = PendingLevels(hero.Xp, hero.Level); for (int i = 0; i < raise; i++) { int before = hero.MaxHp; RaiseLevel(hero); Console.WriteLine(" level " + hero.Level + ": life " + before + " -> " + hero.MaxHp); }}for (int level = 10; level <= 12; level++){ Console.WriteLine("level " + level + ": xp " + XpForLevel(level));}rat +5: xp 5goblin +9: xp 14shaman +12: xp 26 level 2: life 48 -> 57wolf +14: xp 40cultist +20: xp 60 level 3: life 57 -> 66golem +32: xp 92Warden +150: xp 242 level 4: life 66 -> 75 level 5: life 75 -> 84level 10: xp 720level 11: xp 870level 12: xp 1020The Warden (+150) gives two levels at once, 4 and 5. That is why pendingLevels is a loop and not a comparison: one big blow can jump over a threshold. The last three lines show the tail of the table: level 10 is in it (720), while 11 and 12 come from the + 150 per level (870, 1020).
// XP_TABLE[n] is the total experience at which level n is reached.
int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level)
{
if (level < xpTable.Length) return xpTable[level];
// Past the end of the table the curve goes on with 150 per level.
return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;
}
// How many levels are waiting: does the experience reach the next thresholds.
int PendingLevels(int xp, int level)
{
int levels = 0;
while (xp XpForLevel(level + levels + 1)) levels++;
return levels;
}Experience reaches a threshold when it is equal to it or greater: >=. With > a hero with exactly 20 xp would not get level 2, while the threshold in the table means "with this much experience the level is reached". Equality also counts in the "Predict the output" task below.
More precisely, if you are curious
Why a table and not a formula? The table is data: a slower pace, a new level or another mode is another table, not other code. The canon keeps it as numbers separated by | (0|0|20|55|105|170|250|345|455|580|720) and reads it at start-up together with the items and the enemies. The first two zeros are for levels 0 and 1: the hero starts at level 1.
The curve is tuned with the simulation from the last lesson: thousands of games are run and you look at which level the hero reaches against which enemy.
The idea
The skill tree: two paths of three steps
At every new level the player chooses: a stat (+8 life, +1 strength, +1 agility, +1 defense) or a new skill. The skills of one class form a tree with two branches of three steps. The rule is short: a skill is available if it is not learned yet and is either step 1, or the step one lower in the same branch is learned.
record Skill(string Id, int Branch, int Tier);
Skill[] allSkills = [ new Skill("rage", 1, 1), new Skill("whirlwind", 1, 2), new Skill("bloodlust", 1, 3), new Skill("war_cry", 2, 1), new Skill("iron_skin", 2, 2), new Skill("second_wind", 2, 3)];string[] order = ["rage", "war_cry", "whirlwind", "iron_skin", "bloodlust", "second_wind"];
List<string> Learnable(Skill[] skills, List<string> known){ var result = new List<string>(); foreach (var skill in skills) { if (known.Contains(skill.Id)) continue; bool ready = skill.Tier == 1; foreach (var other in skills) { if (known.Contains(other.Id) && other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true; } if (ready) result.Add(skill.Id); } return result;}
string Show(List<string> list){ return list.Count == 0 ? "-" : string.Join(", ", list);}The program learns the barbarian's skills in a given order and after each one prints what is available. An empty list is -.
var known = new List<string>();Console.WriteLine("start -> " + Show(Learnable(allSkills, known)));foreach (var id in order){ known.Add(id); Console.WriteLine(id + " -> " + Show(Learnable(allSkills, known)));}start -> rage, war_cryrage -> whirlwind, war_crywar_cry -> whirlwind, iron_skinwhirlwind -> bloodlust, iron_skiniron_skin -> bloodlust, second_windbloodlust -> second_windsecond_wind -> -At the start only the two starting skills are available: rage and war_cry. Learning rage opens whirlwind, but bloodlust still waits (it needs whirlwind), and the branch of war_cry does not change. At the end second_wind -> -: the tree is used up and only the stats are left in the level-up choice.
The idea
An event bus: whoever publishes does not know who listens
The bus is the simplest thing: a dictionary from an event type to a list of functions. subscribe(type, handler) puts a function in the list for that type. publish(event) calls all the functions in the list for the event's type. Nothing else.
An event is a small record: a type and some data. Here it is type, id (the enemy, the item or the dialog) and depth (the level). Examples are EnemyKilled (an enemy died), TileEntered (the hero entered a level), DialogChoice (an answer was chosen) and ItemPicked (an item was picked up).
record GameEvent(string Type, string Id = "", int Depth = 0){ public string Label => Id == "" ? Depth.ToString() : Id;}
class EventBus{ readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler) { if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>(); handlers[type].Add(handler); }
public void Publish(GameEvent e) { if (!handlers.ContainsKey(e.Type)) return; // A copy of the list: a listener may subscribe or unsubscribe while publishing. foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e); }}The first program puts two listeners on EnemyKilled (a log and the kill counter) and one on TileEntered. Nobody listens to ItemPicked, and the bus simply does nothing.
var bus = new EventBus();var stats = new Stats();bus.Subscribe("EnemyKilled", e => Log(e));bus.Subscribe("TileEntered", e => Log(e));bus.Subscribe("EnemyKilled", e =>{ stats.Kills++; Console.WriteLine("kills: " + stats.Kills);});
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("ItemPicked", "ore"));> EnemyKilled ratlog: EnemyKilled ratkills: 1> TileEntered 2log: TileEntered 2> EnemyKilled wolflog: EnemyKilled wolfkills: 2> ItemPicked oreEvery line with > is a publish, and the lines under it are the listeners that answered, in the order they subscribed. After EnemyKilled rat both answer, after TileEntered 2 only the log, and ItemPicked ore wakes nobody. The code that publishes did not change a line when the listeners appeared.
More precisely, if you are curious
This pattern is called Observer or publish/subscribe. In the UI world it is addEventListener, on servers it is message queues. The game uses the simplest form: everything in one thread, the listeners called at once, one after another. So a listener that does something heavy slows down the whole turn. The listeners are called in the order they subscribed, but you should not rely on that (in the canon no listener depends on another).
publish walks a copy of the list. A quest that starts another quest may subscribe to the same type during publishing; without the copy this throws an exception in Java and C#, and in JavaScript the new listener would be called in the same publish.
The idea
A quest is a state machine
A quest has a state: inactive (not given yet), active (with stage 1, 2, 3), done (completed) and rewarded (the reward is taken). The transitions are not made by the player but by events. So the quest subscribes by itself to the ones that move it and keeps all its logic in one place.
| From | To | When |
|---|---|---|
inactive |
active, stage 1 |
the elder gives the quest (the start action of the dialog) |
active, stage 1 |
stage 2 | TileEntered with depth 2 |
active, stage below 3 |
stage 3 | DialogChoice miner:warden |
active |
done |
EnemyKilled warden |
done |
rewarded |
the dialog runs the reward action |
The base Quest is the common part: a title, a list of goals by stage and three methods that change the state and write to the player. MinersQuest ("The missing miners") is a subclass that only subscribes:
class Quest{ public readonly string Id; public readonly string Title; public readonly string[] Goals; public readonly string DoneGoal; public string State = "inactive"; public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal) { Id = id; Title = title; Goals = goals; DoneGoal = doneGoal; }
public virtual string Goal() { if (State == "done") return DoneGoal; return Goals[Stage - 1]; }
public void Start() { State = "active"; Stage = 1; Console.WriteLine("New quest: " + Title + "."); }
public void Advance(int newStage) { Stage = newStage; Console.WriteLine(Title + ": " + Goal()); }
public void Complete() { State = "done"; Console.WriteLine("Quest complete: " + Title + "!"); }}
class MinersQuest : Quest{ public MinersQuest() : base("miners", "The missing miners", ["Go down into the mine and look for the miners.", "Someone is alive on the second level. Find them.", "Defeat the Warden of the Depths on the third level."], "The Warden is defeated. Greyford is saved.") { }
public void Subscribe(EventBus bus) { bus.Subscribe("TileEntered", e => { if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2); }); bus.Subscribe("DialogChoice", e => { if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3); }); bus.Subscribe("EnemyKilled", e => { if (State == "active" && e.Id == "warden") Complete(); }); }}Every listener starts with state == "active": the bus does not know the state of the quest, so the question is the quest's own. The second condition (stage == 1, stage < 3, id == ...) says which event exactly drives exactly this transition. The base deliberately has no subscribe; in the canon it exists as an empty method, so that the game can walk through all quests and call subscribe on each.
The scenario has eight steps, four of which the quest ignores. The elder gives the quest on the third step, and the steps are exactly the ones the scene below shows.
var bus = new EventBus();var quest = new MinersQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Console.WriteLine("> the elder gives the quest");quest.Start();Fire(bus, new GameEvent("TileEntered", Depth: 1));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("DialogChoice", "miner:warden"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("EnemyKilled", "warden"));> EnemyKilled rat> TileEntered 2> the elder gives the questNew quest: The missing miners.> TileEntered 1> TileEntered 2The missing miners: Someone is alive on the second level. Find them.> DialogChoice miner:wardenThe missing miners: Defeat the Warden of the Depths on the third level.> TileEntered 2> EnemyKilled wardenQuest complete: The missing miners!See it
Events travel to the quest
At the top is the path of the quest with a ball that sits on its state. Every event is a tile: it rises to the bus, goes to the quest, and the quest either moves forward or twitches and says "ignored". You can see which four of the eight events move nothing, and why: the quest has not started yet, the level is different, and for one of them the stage has already been passed.
class EventBus{ readonly Dictionary<string, List<Action<GameEvent>>> handlers = new();
public void Subscribe(string type, Action<GameEvent> handler) { if (!handlers.ContainsKey(type)) handlers[type] = new List<Action<GameEvent>>(); handlers[type].Add(handler); }
public void Publish(GameEvent e) { if (!handlers.ContainsKey(e.Type)) return; // A copy of the list: a listener may subscribe or unsubscribe while publishing. foreach (var handler in new List<Action<GameEvent>>(handlers[e.Type])) handler(e); }}
class Quest{ public readonly string Id; public readonly string Title; public readonly string[] Goals; public readonly string DoneGoal; public string State = "inactive"; public int Stage = 0;
public Quest(string id, string title, string[] goals, string doneGoal) { Id = id; Title = title; Goals = goals; DoneGoal = doneGoal; }
public virtual string Goal() { if (State == "done") return DoneGoal; return Goals[Stage - 1]; }
public void Start() { State = "active"; Stage = 1; Console.WriteLine("New quest: " + Title + "."); }
public void Advance(int newStage) { Stage = newStage; Console.WriteLine(Title + ": " + Goal()); }
public void Complete() { State = "done"; Console.WriteLine("Quest complete: " + Title + "!"); }}
class MinersQuest : Quest{ public MinersQuest() : base("miners", "The missing miners", ["Go down into the mine and look for the miners.", "Someone is alive on the second level. Find them.", "Defeat the Warden of the Depths on the third level."], "The Warden is defeated. Greyford is saved.") { }
public void Subscribe(EventBus bus) { bus.Subscribe("TileEntered", e => { if (State == "active" && Stage == 1 && e.Depth == 2) Advance(2); }); bus.Subscribe("DialogChoice", e => { if (State == "active" && Stage < 3 && e.Id == "miner:warden") Advance(3); }); bus.Subscribe("EnemyKilled", e => { if (State == "active" && e.Id == "warden") Complete(); }); }}Step by step, in words
- The game publishes events to the bus, and the bus calls everyone subscribed to that type. At the top is the quest “The missing miners”: it has not started yet. The ball sits on “inactive”.
- A rat dies (EnemyKilled rat). The quest is subscribed to EnemyKilled, the bus calls it, but the listener asks: is the quest active, and is the enemy the Warden? No: nothing happens. Combat does not know anyone is listening.
- The hero goes down to the second level (TileEntered 2). The depth is right, but the quest is still inactive (state is not "active"), so again nothing. Events arrive when the game says, not when the quest is ready.
- The elder gives the quest: start() makes it active, stage 1. The ball moves to “stage 1”. This is not an event from the bus but an action of the dialog.
- The hero enters level 1 (TileEntered 1). The quest is active and on stage 1, but it wants level 2: depth is 1, so the condition fails. A new event of the same type is not progress.
- Second level (TileEntered 2): active, stage 1 and depth 2. The listener calls advance(2): the ball moves to “stage 2” and the game writes “The missing miners: Someone is alive on the second level. Find them.”.
- The hero talks to the miner and picks “What is down there?”: the dialog publishes DialogChoice miner:warden. The quest is on stage 2 (below 3) and the id matches: advance(3). The ball is on “stage 3”.
- The hero goes to level 2 again (TileEntered 2). depth == 2 is true, but the stage is already 3, not 1: the stage == 1 check stops the event. Without it the quest would slip back to stage 2.
- The Warden dies (EnemyKilled warden). The quest is active and the id is "warden": complete(). The ball is on “done”. Combat only said “the enemy died”; who listens is none of its business.
The code next to the scene is the bus, the quest base and the MinersQuest subclass from the program. At every step the lines that are working at that moment light up.
The scene shows only the quest "The missing miners". Experience and the dialog are in the programs above and below, not in the scene.
The idea
A dialog is a tree with conditions
A conversation is a tree: a node (node) has a line and several answers (option), and every answer leads to a next node (or to end, which closes it). If that is all, the dialog is the same for every player. But Vela should not offer the silver quest if it is already taken, and should not take ore the hero does not have. So an answer has a condition and actions:
- condition (
condition):flag:name,item:item:count,quest:quest:state, with a!in front for negation. Several conditions are separated by commas and all must hold (a logical "and").-means "always". - actions (
actions):flag,start,complete,reward,give,take. They run in order when the answer is chosen.
Conditions and actions are short text in the form kind:argument:argument, because dialogs are data: they can live in a file, not in the code. visibleOptions shows only the answers whose condition is true, and choose runs the actions of the chosen one.
record Option(string Text, string Next, string Condition, string Actions);
record Node(string Text, List<Option> Options);
class Game{ static readonly Dictionary<string, string> Names = new() { ["ore"] = "ore", ["silver_sword"] = "silver sword", ["potion_heal"] = "healing potion", };
public readonly HashSet<string> Flags = new(); public readonly Dictionary<string, int> Items = new(); public readonly Dictionary<string, Quest> Quests = new();
public Game(params Quest[] list) { foreach (var quest in list) Quests[quest.Id] = quest; }
public Quest GetQuest(string id) { return Quests[id]; }
public int Count(string id) { return Items.GetValueOrDefault(id, 0); }
string Label(string id, int n) { return Names[id] + " x" + n; }
public void Give(string id, int n) { Items[id] = Count(id) + n; Console.WriteLine("You receive: " + Label(id, n) + "."); }
public void Take(string id, int n) { Items[id] = Count(id) - n; Console.WriteLine("You hand over: " + Label(id, n) + "."); }}string[] SplitList(string text){ return text == "-" ? [] : text.Split(',');}
bool CheckCondition(Game game, string condition){ foreach (var part in SplitList(condition)) { bool negate = part.StartsWith('!'); var p = (negate ? part.Substring(1) : part).Split(':'); bool ok; if (p[0] == "flag") ok = game.Flags.Contains(p[1]); else if (p[0] == "item") ok = game.Count(p[1]) >= int.Parse(p[2]); else if (p[0] == "quest") ok = game.GetQuest(p[1]).State == p[2]; else throw new ArgumentException("unknown condition: " + part); if (negate) ok = !ok; if (!ok) return false; } return true;}
void RunActions(Game game, string actions){ foreach (var part in SplitList(actions)) { var p = part.Split(':'); if (p[0] == "flag") { game.Flags.Add(p[1]); } else if (p[0] == "start") { game.GetQuest(p[1]).Start(); } else if (p[0] == "complete") { game.GetQuest(p[1]).Complete(); } else if (p[0] == "reward") { var quest = game.GetQuest(p[1]); quest.State = "rewarded"; Console.WriteLine("Reward for " + quest.Title + "."); } else if (p[0] == "give") { game.Give(p[1], int.Parse(p[2])); } else if (p[0] == "take") { game.Take(p[1], int.Parse(p[2])); } else { throw new ArgumentException("unknown action: " + part); } }}
List<Option> VisibleOptions(Game game, Node node){ var result = new List<Option>(); foreach (var option in node.Options) { if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option); } return result;}
void Show(string speaker, Game game, Node node){ Console.WriteLine(speaker + ": " + node.Text); var options = VisibleOptions(game, node); for (int i = 0; i < options.Count; i++) Console.WriteLine(" " + (i + 1) + ") " + options[i].Text);}
string Choose(Game game, Node node, int number){ var option = VisibleOptions(game, node)[number - 1]; Console.WriteLine("> " + number); RunActions(game, option.Actions); return option.Next;}The data are the two dialogs from the game: Vela the smith (items and a quest) and Dobri the miner (a one-time gift with a flag).
var smith = new Dictionary<string, Node>{ ["start"] = new Node("My forge is cold without silver. Bring me three pieces of ore and I will forge you a sword.", [ new Option("I will look for ore.", "deal", "quest:silver:inactive", "start:silver"), new Option("Here is the ore.", "done", "quest:silver:active,item:ore:3", "take:ore:3,complete:silver,reward:silver,give:silver_sword:1"), new Option("Goodbye.", "end", "-", "-")]), ["deal"] = new Node("The ore is on the second level. The ore golems carry it inside them.", [ new Option("Fine.", "end", "-", "-")]), ["done"] = new Node("A silver sword. Its edge never dulls.", [ new Option("Thank you.", "end", "-", "-")]),};
var miner = new Dictionary<string, Node>{ ["start"] = new Node("A living soul! The others ran up... I was trapped down here.", [ new Option("What is down there?", "warden", "quest:miners:active,!flag:miner_told", "flag:miner_told,give:potion_heal:1"), new Option("Stay here, I will come back.", "end", "-", "-")]), ["warden"] = new Node("The Warden of the Depths. A stone guardian woken by our picks. When it cracks, it calls the dead to help. Take my potion.", [ new Option("I will stop it.", "end", "-", "-")]),};var game = new Game(new Quest("silver", "Silver for the smith", ["Collect three pieces of silver ore.", "Bring the ore to Vela the smith."], "The smith has her silver."), new Quest("miners", "The missing miners", ["Go down into the mine and look for the miners.", "Someone is alive on the second level. Find them.", "Defeat the Warden of the Depths on the third level."], "The Warden is defeated. Greyford is saved."));var start = smith["start"];Show("Vela", game, start);string next = Choose(game, start, 1);Show("Vela", game, smith[next]);game.Give("ore", 3);Show("Vela", game, start);next = Choose(game, start, 1);Show("Vela", game, smith[next]);game.GetQuest("miners").Start();var minerStart = miner["start"];Show("Dobri", game, minerStart);next = Choose(game, minerStart, 1);Show("Dobri", game, miner[next]);Show("Dobri", game, minerStart);Vela: My forge is cold without silver. Bring me three pieces of ore and I will forge you a sword. 1) I will look for ore. 2) Goodbye.> 1New quest: Silver for the smith.Vela: The ore is on the second level. The ore golems carry it inside them. 1) Fine.You receive: ore x3.Vela: My forge is cold without silver. Bring me three pieces of ore and I will forge you a sword. 1) Here is the ore. 2) Goodbye.> 1You hand over: ore x3.Quest complete: Silver for the smith!Reward for Silver for the smith.You receive: silver sword x1.Vela: A silver sword. Its edge never dulls. 1) Thank you.New quest: The missing miners.Dobri: A living soul! The others ran up... I was trapped down here. 1) What is down there? 2) Stay here, I will come back.> 1You receive: healing potion x1.Dobri: The Warden of the Depths. A stone guardian woken by our picks. When it cracks, it calls the dead to help. Take my potion. 1) I will stop it.Dobri: A living soul! The others ran up... I was trapped down here. 1) Stay here, I will come back.Watch how Vela's answers change for the same node start: on the first visit there are two (deal and "Goodbye"), because done needs the quest and ore. After the hero brings three ore she no longer offers deal (the quest is active) but offers done. The actions of done take the ore, close the quest, give the reward and the silver sword. With Dobri the answer "What is down there?" disappears as soon as the flag miner_told is set: the gift is one-time.
The idea
In the game: experience, skills and the bus
Here are the real excerpts from the canon. First, the levels and the tree (xp-levelup). Besides our xpForLevel and pendingLevels there is learnableSkills (only the skills of the hero's class, from the hero's list), levelUpChoices (four stats and the available skills) and applyLevelChoice:
// The level curve is a table: XP_TABLE[n] is the XP that reaches level n.class LevelChoice{ public string Kind = ""; public string Stat = ""; public int Amount;}
static partial class Arcanum{ public static int XpForLevel(int level) { if (level < XP_TABLE.Count) return XP_TABLE[level]; return XP_TABLE[XP_TABLE.Count - 1] + (level - XP_TABLE.Count + 1) * 150; }
public static int PendingLevels(Hero hero) { int levels = 0; while (hero.Xp >= XpForLevel(hero.Level + levels + 1)) levels++; return levels; }
// The new level by itself: more life and mana, full health. public static void RaiseLevel(Hero hero) { HeroClass heroClass = hero.HeroClass(); hero.Level += 1; hero.Base["maxHp"] = hero.Base["maxHp"] + heroClass.HpPerLevel; hero.Base["maxMana"] = hero.Base["maxMana"] + heroClass.ManaPerLevel; hero.Hp = hero.MaxHp(); hero.Mana = hero.MaxMana(); }
// The skill tree: two branches of three steps. A step can be learned if the one before it is learned. public static List<string> LearnableSkills(Hero hero) { List<string> result = new List<string>(); foreach (Skill skill in SKILLS.Values()) { if (skill.ClassId != hero.ClassId || hero.Skills.IndexOf(skill.Id) >= 0) continue; bool ready = skill.Tier == 1; foreach (string known in hero.Skills) { Skill other = SKILLS.Get(known); if (other.Branch == skill.Branch && other.Tier == skill.Tier - 1) ready = true; } if (ready) result.Add(skill.Id); } return result; }
// The choices at a new level: a stat or a new skill. public static List<LevelChoice> LevelUpChoices(Hero hero) { List<LevelChoice> choices = new List<LevelChoice> { new LevelChoice { Kind = "stat", Stat = "maxHp", Amount = 8 }, new LevelChoice { Kind = "stat", Stat = "strength", Amount = 1 }, new LevelChoice { Kind = "stat", Stat = "agility", Amount = 1 }, new LevelChoice { Kind = "stat", Stat = "defense", Amount = 1 }, }; foreach (string skillId in LearnableSkills(hero)) choices.Add(new LevelChoice { Kind = "skill", Stat = skillId, Amount = 0 }); return choices; }
public static void ApplyLevelChoice(Hero hero, LevelChoice choice) { if (choice.Kind == "skill") { hero.Skills.Add(choice.Stat); } else { hero.Base[choice.Stat] = hero.Base[choice.Stat] + choice.Amount; if (choice.Stat == "maxHp") hero.Hp = hero.MaxHp(); } }}The differences from our program are few:
raiseLevelin the game adds mana too and restores full health; we only touch maximum life.- The level is not given in
gainXp.enemyKilled(besides the gold and the corpse) raiseshero.xpand publishes an event, andqueueLevelUpsputs aLevelUpScreenon the stack (the lesson about screens): the player chooses a stat or a skill and only then doraiseLevelandapplyLevelChoicechange the hero. There is one screen per level, not one dialog for all of them. - The stats change in
base(the lesson about entities), not in the hero itself.
And the bus is the same as ours:
// The event bus: the publisher does not know who listens. Quests subscribe and track their own// progress, and the combat system only says "the enemy died".class GameEvent{ public string Type = ""; public int Depth; public int X; public int Y; public char Decor = ' '; public string Marker = ""; public string ItemId = ""; public int Count; public string KindId = ""; public string DialogId = ""; public string NodeId = "";}
class EventBus{ public Dictionary<string, List<Action<GameEvent>>> Handlers = new Dictionary<string, List<Action<GameEvent>>>();
public void Subscribe(string type, Action<GameEvent> handler) { if (!Handlers.ContainsKey(type)) Handlers[type] = new List<Action<GameEvent>>(); Handlers[type].Add(handler); }
public void Publish(GameEvent gameEvent) { if (!Handlers.ContainsKey(gameEvent.Type)) return; foreach (Action<GameEvent> handler in new List<Action<GameEvent>>(Handlers[gameEvent.Type])) handler(gameEvent); }}The events in the game are free-form objects ({ type: 'EnemyKilled', kindId: … }), with different fields for each type: kindId, itemId, dialogId with nodeId, depth, decor, marker. In Java, C# and Python each shape is a separate record. We tied everything to type, id and depth, so that one type fits all four languages.
The idea
In the game: quests and dialogs
Here are the real quests. The base Quest uses keys to the texts (t('quest.' + id + '.title')) so that the game can be translated; MissingMinersQuest is ours, and the other two show what else a state machine can do:
// A quest is a small state machine: inactive → active (stages) → done → rewarded. It subscribes// by itself to the events that move it forward.abstract class Quest{ public string Id; public string State; public int Stage;
public Quest(string id) { Id = id; State = "inactive"; Stage = 0; }
public string Title() { return T("quest." + Id + ".title"); }
public string Goal() { if (State == "done") return T("quest." + Id + ".done"); if (State == "rewarded") return T("quest.rewarded"); return T("quest." + Id + ".stage" + Stage); }
public virtual void Start(Game game) { State = "active"; Stage = 1; game.Say(T("quest.started", ("title", Title()))); }
public void Advance(Game game, int stage) { Stage = stage; game.Say(T("quest.updated", ("title", Title()), ("goal", Goal()))); }
public void Complete(Game game) { State = "done"; game.Say(T("quest.completed", ("title", Title()))); }
public virtual void Subscribe(EventBus bus, Game game) { }}
class MissingMinersQuest : Quest{ public MissingMinersQuest() : base("miners") { }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("TileEntered", (gameEvent) => { if (State == "active" && Stage == 1 && gameEvent.Depth == 2) Advance(game, 2); }); bus.Subscribe("DialogChoice", (gameEvent) => { if (State == "active" && Stage < 3 && gameEvent.DialogId == "miner" && gameEvent.NodeId == "warden") Advance(game, 3); }); bus.Subscribe("EnemyKilled", (gameEvent) => { if (State == "active" && gameEvent.KindId == "warden") Complete(game); }); }}
class BloodTrailQuest : Quest{ public BloodTrailQuest() : base("blood") { }
public override void Start(Game game) { base.Start(game); if (game.HasFlag("leader_dead")) Complete(game); }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("TileEntered", (gameEvent) => { if (State != "active" || gameEvent.Depth != 1) return; if (Stage == 1 && gameEvent.Decor == ',') Advance(game, 2); if (Stage < 3 && gameEvent.Marker == "lair") Advance(game, 3); }); bus.Subscribe("EnemyKilled", (gameEvent) => { if (gameEvent.KindId != "wolf_leader") return; game.SetFlag("leader_dead"); if (State == "active") Complete(game); }); }}
class SilverQuest : Quest{ public SilverQuest() : base("silver") { }
public override void Start(Game game) { base.Start(game); Check(game); }
public void Check(Game game) { if (State == "active" && Stage == 1 && game.Hero.Inventory.Count("ore") >= 3) Advance(game, 2); }
public override void Subscribe(EventBus bus, Game game) { bus.Subscribe("ItemPicked", (gameEvent) => { if (gameEvent.ItemId == "ore") Check(game); }); }}
static partial class Arcanum{ public static List<Quest> CreateQuests() { return new List<Quest> { new MissingMinersQuest(), new BloodTrailQuest(), new SilverQuest() }; }}- A quest that starts after its condition.
BloodTrailQuest.startandSilverQuest.startcheck at once whether the condition is already true: the wolf leader may already be dead, or the ore already in the backpack, before the quest is taken. That way the quest does not stay on stage 1 forever. OurMinersQuestdoes not do this, because the Warden does not die before the talk with the elder. - Stages with more than one source.
BloodTrailQuestlistens toTileEnteredin two ways: for a blood trail (decor) and for the den (marker). The events do not have to be coordinated: the listener asks about its own state and answers. - Reaching the victory. The Warden is not only
EnemyKilled: the game setsvictoryinenemyKilled, and the quest moves on the same event. The quest "The missing miners" has no reward: the victory shows the victory screen.
The dialog in the canon is what we built, and a little more: the actions include gold and xp (the hunter's reward), and the conditions include gold: (enough gold):
// A dialog is a tree with conditions: choices whose conditions are not met are not shown.static partial class Arcanum{ public static bool CheckCondition(Game game, string condition) { foreach (string part in SplitList(condition)) { bool negate = part[0] == '!'; string[] pieces = (negate ? part.Substring(1) : part).Split(':'); bool ok = false; if (pieces[0] == "flag") ok = game.HasFlag(pieces[1]); else if (pieces[0] == "item") ok = game.Hero.Inventory.Count(pieces[1]) >= ToInt(pieces[2]); else if (pieces[0] == "gold") ok = game.Hero.Gold >= ToInt(pieces[1]); else if (pieces[0] == "quest") ok = game.Quest(pieces[1]).State == pieces[2]; else throw new Exception("unknown condition: " + part); if (negate) ok = !ok; if (!ok) return false; } return true; }
public static void RunActions(Game game, string actions) { foreach (string part in SplitList(actions)) { string[] pieces = part.Split(':'); if (pieces[0] == "flag") { game.SetFlag(pieces[1]); } else if (pieces[0] == "start") { game.Quest(pieces[1]).Start(game); } else if (pieces[0] == "complete") { game.Quest(pieces[1]).Complete(game); } else if (pieces[0] == "reward") { game.Quest(pieces[1]).State = "rewarded"; game.Say(T("quest.rewardedSay", ("title", game.Quest(pieces[1]).Title()))); } else if (pieces[0] == "give") { game.GiveItem(pieces[1], ToInt(pieces[2])); } else if (pieces[0] == "take") { game.Hero.Inventory.Remove(pieces[1], ToInt(pieces[2])); game.Say(T("item.given", ("item", ItemLabel(pieces[1], ToInt(pieces[2]))))); } else if (pieces[0] == "gold") { game.Hero.Gold += ToInt(pieces[1]); game.Say(T("item.gold", ("amount", pieces[1]))); } else if (pieces[0] == "xp") { game.GainXp(ToInt(pieces[1])); } else { throw new Exception("unknown action: " + part); } } }
public static List<DialogOption> VisibleOptions(Game game, DialogNode node) { List<DialogOption> result = new List<DialogOption>(); foreach (DialogOption option in node.Options) { if (option.Condition == "-" || CheckCondition(game, option.Condition)) result.Add(option); } return result; }}DialogScreen (the screen on the stack) calls visibleOptions, and when the player picks an answer it runs runActions and publishes DialogChoice with the next node. So the dialog and the quest do not know each other: the talk with the miner moves the quest only because the dialog published and the quest listened.
Try it
Predict the output
Three calculations of pendingLevels with the table from the lesson. What does the program print?
// XP_TABLE[n] is the total experience at which level n is reached.int[] xpTable = [0, 0, 20, 55, 105, 170, 250, 345, 455, 580, 720];
int XpForLevel(int level){ if (level < xpTable.Length) return xpTable[level]; // Past the end of the table the curve goes on with 150 per level. return xpTable[xpTable.Length - 1] + (level - xpTable.Length + 1) * 150;}
// How many levels are waiting: does the experience reach the next thresholds.int PendingLevels(int xp, int level){ int levels = 0; while (xp >= XpForLevel(level + levels + 1)) levels++; return levels;}
Console.WriteLine(PendingLevels(130, 1));Console.WriteLine(PendingLevels(104, 3));Console.WriteLine(PendingLevels(55, 1));With 130 xp and level 1 the thresholds 20, 55 and 105 are reached (130 ≥ 105), and 170 is not: three levels. With 104 xp and level 3 the next threshold is 105, and 104 does not reach it: zero. With 55 xp and level 1 the thresholds are 20 and 55 (55 ≥ 55, equality counts), and 105 is not: two. The second answer uses > instead of >=; the third counts from xpForLevel(level), that is, it also counts the level the hero is already on.
Try it
Find the bug
A colleague removed one check in MinersQuest and now the quest goes backwards. The hero has already talked to the miner (stage 3) and goes down to the second level again:
var bus = new EventBus();var quest = new MinersQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Console.WriteLine("> the elder gives the quest");quest.Start();Fire(bus, new GameEvent("TileEntered", Depth: 1));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("DialogChoice", "miner:warden"));Fire(bus, new GameEvent("TileEntered", Depth: 2));Fire(bus, new GameEvent("EnemyKilled", "warden"));> EnemyKilled rat> TileEntered 2> the elder gives the questNew quest: The missing miners.> TileEntered 1> TileEntered 2The missing miners: Someone is alive on the second level. Find them.> DialogChoice miner:wardenThe missing miners: Defeat the Warden of the Depths on the third level.> TileEntered 2The missing miners: Someone is alive on the second level. Find them.> EnemyKilled wardenQuest complete: The missing miners!At the end of the output, after the third TileEntered 2 (the hero has already talked to the miner), the goal is again "Someone is alive on the second level", although the hero already knows about the Warden. Which line causes this?
Pick the line with the bug.
The condition of TileEntered does not ask which stage the quest is on: state == "active" and depth == 2 are true on stage 3 as well, so going down calls advance(2) again. Correct: stage == 1 && depth == 2. The bus does not keep the order of events, so every listener checks both the state and the stage. The bug is silent: nothing throws, only the goal in the quest log is wrong.
More precisely
More precisely: listener order, exceptions and determinism
Synchronous. publish returns when all the listeners have finished. Everything is in one thread, so a listener that publishes a new event fires it inside the first one (nested publishing). If quest A listens to EnemyKilled and publishes QuestDone, and quest B listens to QuestDone, B is called before the first publish ends. Limit the depth: an endless loop of events is the hardest bug of buses.
Listener order. The order of subscription is deliberately predictable (a list, not a set): the game must give the same result for the same seed in all four languages. But if listeners depend on the order, the design is wrong: two listeners that need each other should be one.
An exception in a listener. Here it interrupts publish, and the listeners after it are not called. In a single-player game that is acceptable (better to see the error); in a server you wrap it in try.
Unsubscribing. Our bus has no unsubscribe. A quest that is done stays subscribed and asks state == "active" every time; that is cheap. Where listeners are many or short-lived (elements on a screen), without unsubscribing "dangling" listeners and memory that is never returned are left behind.
The machine grows. Five states with two transitions each are ten if lines. When a quest branches (choose a side, fail after a time, check three goals in any order), the state is no longer a single value. Then every goal is its own small machine, and the quest is "done" when all of them are. The canon deliberately stops before that.
A dialog as data. Our format is a string kind:arg:arg. An advantage: the dialog can be in a file and changed without new code. A cost: a mistake in a name (quets:silver:active) is caught only at run time (unknown condition), not at compile time. That is why checkCondition and runActions throw for an unknown kind instead of quietly skipping it.
Exercise
Three wolves
Solve the task in your editor. Start from the program for the quest.
The quest "Three wolves". Write WolfQuest (a subclass of Quest) with one goal, "Kill three wolves": the quest counts the wolves killed and moves to done at the third. It listens only to EnemyKilled; it counts only wolves (id is "wolf") and only while it is active. For every wolf killed that is not the last, it writes the goal with the counter, for example "Kill three wolves (1/3)" (override goal()), by calling advance(1). At the third it calls complete(). Events before start() and after done do not count.
The base Quest, EventBus and fire are the same as in the program for the quest. Here is the scenario:
var bus = new EventBus();var quest = new WolfQuest();quest.Subscribe(bus);
Fire(bus, new GameEvent("EnemyKilled", "wolf"));Console.WriteLine("> start");quest.Start();Fire(bus, new GameEvent("EnemyKilled", "rat"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));Fire(bus, new GameEvent("EnemyKilled", "wolf"));The output is:
> EnemyKilled wolf> startNew quest: Three wolves.> EnemyKilled rat> EnemyKilled wolfThree wolves: Kill three wolves (1/3)> EnemyKilled wolfThree wolves: Kill three wolves (2/3)> EnemyKilled wolfQuest complete: Three wolves!> EnemyKilled wolfMore tasks
- Reward. Add a method
reward()toQuestthat goes fromdonetorewardedand writes the reward. Then make an answer in the hunter's dialog with the conditionquest:wolves:doneand the actionreward:wolves. - Two quests at once. Subscribe both
MinersQuestandWolfQuestto the same bus and write a scenario in which oneEnemyKilledmoves both. Why is onepublishenough? - Unsubscribing. Add
unsubscribe(type, handler)to the bus. What do you have to do inpublishso that a listener can unsubscribe during publishing? - The skill tree. Add a third class with two branches in which the second step needs two learned steps (one from each branch). What does this change in
learnable?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Lists · Dictionary and HashSet · Lambdas and delegates · record, switch and patterns
- Tasks on this topic: Chronicles of Arcanum