Designing a Story: Building Narratives from Story Atoms
This is an essay in the Herodotus series. Please read at least Introducing Herodotus before proceeding.
Rboehme's Quest Generator
Areae's Metaplace is an upcoming virtual world platform, construction kit, and social network. One of the exciting ideas in the days leading up to its launch is the plan by Rboehme and others on the Metaplace forums to write a procedural quest generator. Since this sort of algorithm is precisely what Herodotus was written for, I hope to contribute to the project by providing a Herodotus simulation back-end for it. This essay will also introduce Thucydides, a simulation toolkit for Herodotus.
The Father of History and the Father of Lies
Herodotus, the man, went by an additional epithet: "The Father of Lies". This ignominious label was conferred upon him by his critics who detested his ways of presenting multiple conflicting accounts of a story and his habit of writing on local folklore or third- or fourth-hand reports. Thucydides, a slightly later historian, introduced the "objective" approach to history that historians use today, meaning that he competes for the title of "The Father of History". Since Thucydides's work was more responsible and more restrained, I thought it just that the technology which provided a framework for history and a clear structure for the simulation framework should be called Thucydides.
Thucydides uses the context feature of Fluents and the forwarding mechanism of Io to conceal the inner workings of Herodotus from client programs. A Thucydides SimulationObject triggers an Incident when it is created or destroyed, is uniquely identifiable among other SimulationObjects, and passes on any slot requests such as age or height, parameterized with a time, to the History in which they're defined. What this means is that a lot of duplicated or error-prone Herodotus API can be replaced with the use of these SimulationObjects. Furthermore, it's possible to mix Thucydides and Herodotus code, as long as the user is careful not to violate any assumptions the SimulationObjects might have about whether their represented contexts are still valid, whether their histories still exist, etc.
In this essay, we'll only be considering a single History, and will attempt to show how Thucydides can express Rboehm's Quest Generator.
About the Simulation
As I understand it, Rboehme's basic approach is to define some terms as follows:
- Story
- A context in which Quests take place.
- Quest
- A series of Scenes designed to express a unit of Story.
- Scene
- A setting involving specific sets of necessary, sufficient, and optional goals.
- Character
- An entity placed at a specific Location, with a Memory of things which have occurred to him before in the short, medium, and long terms. Also includes a list of Goals which can be used to feed the quest generator, as well as a list of likes and dislikes and a list of relationships to other Characters.
- Role
- Provides a list of Goals that a Character fulfilling that Role can choose from. For instance, a Priest might choose the "Peaceful Resolution" Goal for the "Ender of the Brawl" Role, whereas a Barbarian might choose the "Pacify By Force" Goal. Also includes information about whether the Character is a major, minor, or walk-on personage. These Roles and their Goals drive AI behavior or PC options.
- Goal
- An abstract Goal (ROLE brings ITEM to ROLE, ROLE compels ROLE to ACTION, ROLE removes ROLES from AREA, etc). Goal slots are filled with Roles provided by the Scene.
For example, in a standard "fetch quest", the first scene, or Start, of the Quest would include the part where the player approaches the NPC and asks for a mission. Its Goal would be ROLE asks ROLE for REQUEST, with the Roles being CUSTOMER and DELIVERYMAN respectively, and the REQUEST being for a particular ITEM. This ITEM would be determined by the CUSTOMER's Location, likes/dislikes, and other attributes. This Goal would be part of the "Customer" Role, and the "Deliveryman" (our player) must wait for it to happen. It might happen as part of conversation, or the distraught NPC might run up to the player and demand help.
In turn, this Scene would seed the Goal for the next Scene: ROLE brings ITEM to ROLE, with DELIVERYMAN and CUSTOMER being the Roles in order this time, and the ITEM being the one previously decided. It is in this next scene, Finish, that the traditional MMO fetch quest takes place as the player completes that goal.
If this seems like a lot of work for a simple fetch quest, that might be excused; but consider that this description is not just one fetch quest, but every fetch quest. By varying the ITEM and its Location (or Owner), or by using an ITEM which requires some special preparation before it can transform into the "true" ITEM, all kinds of fetch quests can be produced. The primary benefit of quest synthesis is that you can script the variation into the objects, rather than into the quest itself.
In terms of an overarching Story, the player's perceived Goal can shift radically from Quest to Quest depending on the Goal used to reach completion at a given Quest. If this Goal is a "failure"-like Goal, that has ramifications on what Quests are available afterwards; if it's a success and a part of a chain, the future Quests themselves continue the theme.
In Terms of Herodotus
Now, we'll rephrase these building blocks of Rboehme's quest simulation as Herodotus constructs, building a quest where a farmer wants a donut from the player. A delicious donut is hidden in the nearby cave, but a passable donut is available on the farm itself. Once we've met the requirements of Herodotus, we'll again recast the simulation as Thucydides entities, just to show how the process works.
We'll build from the simplest components of the system up to the most complex ones.
Location, location, location
First, we'll place the static tokens in the world. A complete implementation would include a large number of Locations for quests to take place in, or Characters to live in, but for now we'll make do with three: Nowhere; a Farm; and a Cave. To implement these in straight Herodotus, we would say something like this:
history := History clone Location := Object clone Nowhere := Location clone CreateNowhere := Incident clone do( setFluent(FAppendContext create(list("locations", "nowhere"), Nowhere)) setTime(0) ) CreateNowhere occurIn(history) //And later, in our quest generator... Farm := Location clone Cave := Location clone CreateFarm := Incident clone do( setFluent(FAppendContext create(list("locations", "farms"), Farm)) setTime(0) ) CreateCave := Incident clone do( setFluent(FAppendContext create(list("locations", "caves"), Cave)) setTime(0) ) CreateFarm occurIn(history) CreateCave occurIn(history)
In Thucydides:
history := History clone //Go ahead and use this history for all SimObjects SimObject setHistory(history) Location := SimObject clone appendCategory("locations") setSpawnTime(0) Nowhere := Location clone appendCategory("nowhere") Nowhere spawn //And later, in our quest generator... Farm := Location clone appendCategory("farms") Cave := Location clone appendCategory("caves") Farm spawn Cave spawn
From here, we can still do all the usual Herodotus queries on that History; spawn just triggers an Incident, after all.
Things and Stuff
Continuing with the simple pieces, let's consider Items next. Items can provide arbitrarily complex goals, but for now we'll treat Items as tokens to be grabbed. For now, we'll just make two Donuts of differing quality. In raw Herodotus:
Item := Object clone do( categories ::= list("items") ) //And later, in our quest generator... Donut := Item clone do( categories := list("items", "donuts") //On a scale of 1-10 baseQuality ::= 5 ) LousyDonut := Donut clone setBaseQuality(2) TastyDonut := Donut clone setBaseQuality(8) CreateDonut := Incident clone do( donut ::= nil loc ::= Nowhere setFluent(FAppendContext create(donut categories, donut)) //Establish the donut's start-position and initial quality setFluent(FReplaceConstant create("location", donut, loc) setFluent(FReplaceConstant create("quality", donut, donut baseQuality) //Donuts degrade as time goes on. //If one unit of time is one hour, a donut is absolutely bad //after 24 time units, or one day. setFluent("quality", donut, h, t, ((1 - (t - time)) / 24) max(0) ) stackBy(MultiplyNumber) setTime(0) ) CreateDonut clone do( setLocation(Farm) setDonut(LousyDonut) occurIn(history) ) CreateDonut clone do( setLocation(Cave) setDonut(TastyDonut) occurIn(history) )
And in Thucydides:
Item := SimObject clone appendCategory("items") //And later, in our quest generator... Donut := Item clone appendCategory("donuts") do( setQuality(5) setLocation(Nowhere) //Intrinsics are a list of Fluents that go hand-in-hand //with a SimObject and are applied as soon as it spawns. intrinsics := list( //Donuts degrade as time goes on. //If one unit of time is one hour, a donut is absolutely bad //after 24 time units, or one day. Fluent create("quality", self, h, t, ((1 - (t - time)) / 24) max(0) ) stackBy(MultiplyNumber) ) ) LousyDonut := Donut clone setQuality(2) setLocation(Farm) TastyDonut := Donut clone setQuality(8) setLocation(Cave) LousyDonut spawn TastyDonut spawn
The fascinating trick in this example is the way the "setQuality" and "setLocation" calls work. Note that those methods aren't defined on Item or Donut or, indeed, on SimObject! So, how are they resolved?
They are handled by SimObject's forward(), which deals with unrecognized messages, and the rule they use is as follows: If the message starts with "set", remove the "set" and treat the lowercase name as a fluent type. If the object hasn't spawned yet and no time argument is provided, append to the Intrinsic Fluents an FReplaceConstant Fluent with the given fluent type and using the SimObject as context. If the object has spawned already, require a time argument in the first position. If there's a time argument, create and apply a new Incident using an FReplaceConstant Fluent. SimObject's forward mechanism works similarly for messages beginning with "add", "subtract", "multiply", "divide", "append", and "remove". This removes the need for a lot of very similar Herodotus API uses.
Note that these only create one-way links - if you take two SimObjects A and B and perform A appendFriend(B), a fluent of type friend will be created with the context {A} and a result list containing B. The reverse relationship will not hold. If Friendship is always two-way, then it might be reasonable to create a Friendship Relationship and attach both A and B to it via something like Relationship clone appendCategory("friends") setParticipants(list(A, B)) setStrength(0.5). Relationship is a Thucydides SimObject clone which is responsible for maintaining simple relationship statuses between a group of SimObjects. It's very simple, but it provides a nice single point of use. A Relationship's participants, strength, and other slots are, of course, time-based, mutable, and queriable as usual.
Goals and Roles
Here is where we need to think a little more about what we're doing, exactly. A Scene's Goal is a test of whether some conditions are met. There are several ways we can express this:
- An Invariant on a Scene. If a Goal is met, the Scene ends. This means that subsequent Scenes must handle any "mess" that results from a Scene ending abruptly. This is the only way that Herodotus can automatically handle Scene endings.
- A plain Object property of a Scene and of various Roles. As actions are performed and Incidents are generated and added, check to see if any Scene's Goals are met. If so, permit them to terminate gracefully.
- As above, but instead creating a Goal SimObject and associating it through Relationships with a Scene.
The difference between the first two and the last one is whether Goals are mutable during a Scene. This comes down to a question of whether a "changing goal" means "an alternate goal that was always available", "the goal of a new scene triggered by the 'failure' of this scene", or "an actual shift in goals of the same scene". Personally, I think the middle option is the best, and a Quest can potentially be many Scenes in a row, with each Scene having one static set of goals. So, if the goal "shifts" from Player delivers the Letter to the Captain to Player reports to the Captain that the Letter has been destroyed, what actually occurred is that the Goal Player discovers the Letter's destruction was achieved, which ended the first scene and segued into the second scene, which included a primary goal of Player reports to the Captain that the Letter has been destroyed. Fluent-driven objects are powerful, but add complexity. If it's possible to simplify the model by making certain parts static, feel free to do so.
Now then, let's express two Goals: Farmer asks Player to bring him a Donut and Player delivers a Donut to the Farmer
//This is a barebones Goal with a very simple completion check. //There's no reason Goals couldn't check certain fluent values //on their Roles, or other arbitrary behavior. Goal := Object clone do( availableTime ::= 0 satisfiedInAt := method(h, t, h fluentValue("satisfied", self, t) ) ) AskForItemGoal := Goal clone do( item ::= nil asker ::= nil provider ::= nil ) GiveItemGoal := Goal clone do( item ::= nil receiver ::= nil giver ::= nil ) //And later, in our quest generator... AskForDonutGoal := AskForItemGoal clone do( setItem(Donut) setAsker(Farmer) setProvider(Player) ) GiveDonutGoal := GiveItemGoal clone do( setItem(Donut) setReceiver(Farmer) setGiver(Player) )
The above example actually looks identical in Thucydides. This is to be expected, since none of this uses Herodotus. Now, just to illustrate a fancier satisfiedInAt condition, we'll show what would happen if the GiveItemGoal were to check for evidence of a Transaction between the giver and receiver involving that item.
Transaction := Object clone do( aItem ::= nil bItem ::= nil aParty ::= nil bParty ::= nil aItemCost ::= 0 bItemCost ::= 0 time ::= 0 ) //... //This is just an example of a complex goal in GiveItemGoal. //It could just as easily have been done on the accomplishing side //by manually setting "goalSatisfied". satisfiedInAt := method(h, t, h fluentValue("transactions", nil, t) detect(i, v, //Make sure this transaction is from the giver to the receiver, //that it involves the right item type, and that it happened after //the goal became available (v aParty == giver) and( v bParty == receiver) and( v aItem categories containsAll(item categories)) and( v time > availableTime ) ) )
And, in Thucydides, a couple of small changes:
Transaction := SimObject clone do( appendCategory("transactions") //These are all static, so we don't need to change them, //which means they're "real" slots and not Fluent slots. aItem ::= nil bItem ::= nil aParty ::= nil bParty ::= nil aItemCost ::= 0 bItemCost ::= 0 //Time is covered by the SimObject. //time ::= 0 ) satisfiedInAt := method(h, t, //Make sure this transaction is from the giver to the receiver, //that it involves the right item type, and that it happened after //the goal became available Transaction allActiveInAt(h, t) detect(i, v, (v giver == giver) and( v receiver == receiver) and( v item categories containsAll(item categories)) and( v time > availableTime ) ) )
There's not much to be gained in this specific case from using this feature, though, so we'll stick with the simpler version above. As for actually fulfilling these goals, that can be done in places like the hypothetical giveItem() or converseWithNPC(), or some other place. There, too, we can record in the receiving character's inventory fluent the receipt of the good or the bad donut, and even influence their mood based on how good a donut it was.
Next, let's introduce Roles. A Role, for a Scene, includes a set of Goals, a list of characters, and some metadata. In the Scene where an Asker asks a Provider to bring him an Item, the Asker Role would have a single Goal, which is within the Scene's ending goals, and the Provider would have none, except implicitly to be asked about bringing the Item. The Item is chosen from among the likes or needs of the Character fulfilling the Asker Role.
We'll express Roles, too, as static data, and assume that once a Character is set on a Role, he won't switch or lose that Role for the duration of the Scene.
Role := Object clone do( //Categories are metadata about the sort of role it is. categories ::= list() //The characters slotted into this role. characters ::= list() //Those goals that the characters should want to fulfill //to properly execute this Role. goals ::= list() ) //And later, in our quest generator... //Scene one: DonutAsker := Role clone do( //This "consumer" category means "someone who takes something in" setCategories(list("consumer")) //In other words, it's the Farmer in this Scene. Generally, a Category //is like a meta-Role that spans the whole Scene. setCharacters(list(Farmer)) setGoals(list(AskForDonutGoal)) ) DonutProvider := Role clone do( //The Provider is "someone who supplies something to a consumer". setCategories(list("provider")) setCharacters(list(Player)) ) //Scene two: DonutReceiver := Role clone do( setCategories(list("consumer")) setCharacters(list(Farmer)) ) DonutGiver := Role clone do( setCategories(list("provider")) setCharacters(list(Player)) setGoals(list(GiveDonutGoal)) )
Easy enough, and identical in Thucydides, since we assume that Roles are, basically, unchanging tokens.
Building Character
Since this example is already running long, we'll make Characters as simple as possible: a list of desired items and a location. In a full implementation, we'd include personal goals, memories, and so on, but for now we don't want to make it seem like I'm being paid by the word. We'd like Characters to move around and shift their desires, though, so let's go ahead and see that in Herodotus:
Character := Object clone CreateCharacter := Incident clone do( //Initial state. desires ::= list() location ::= Nowhere character ::= nil setupFluents := method( //As usual, add the context... setFluent(FAppendContext("characters", character)) //Then add the initial state, like desires, and //link it to the character... setFluent(FReplaceList("desires", character, desires)) //Add the location too setFluent(FReplaceConstant("location", character, location)) ) ) //And later, in our quest generator... Farmer := Character clone Player := Character clone //Remember, this is an Incident. CreateFarmer := CreateCharacter clone do( //The Farmer wants donuts setDesires(list("donuts")) //and he lives on the Farm setLocation(Farm) //and he's represented by Farmer setCharacter(Farmer) ) CreatePlayer := CreateCharacter clone do( setLocation(Farm) setCharacter(Player) ) CreateFarmer occurIn(history) CreatePlayer occurIn(history)
In Thucydides:
//Thucydides makes this much smoother Character := SimObject clone do( appendCategory("characters") setLocation(Nowhere) setDesires(list()) ) //And later, in our quest generator... Farmer := Character clone do( //Fluent appending works on a whole list at once, so it uses //the plural. Sorry, but it was the easiest way to do it //with the forwarding mechanism. appendDesires(list("donuts")) setLocation(Farm) ) Player := Character clone do( setLocation(Farm) ) Farmer spawn Player spawn
Note the use of "appendDesires", one of the forward-handled methods from before.
Making a Scene
The Scene level is the first place we see tying together all these independent objects. Recall that a Scene has a set of Roles and some Goals, and if certain of those Goals are met the Scene is concluded. Fortunately, scenes are static! There's nothing in a Scene that must vary with time.
Scene := Object clone do( //The roles appearing in the scene roles ::= list() //The goals that, when any are accomplished, satisfy the scene finishGoals ::= list() //A list of blocks of the form (scene, oldScene, h, t)->bool preconditions ::= list() //Delegate finished status to goals satisfiedInAt := method(h, t, finishGoals detect(i,v, v satisfiedAtIn(h, t) ) ) //Is it valid to segue into this? canSegueFromInAt := method(oldScene, h, t, //If any precondition block forbids it, fail to segue preconditions foreach(i,v, if(v call(self, oldScene, h, t) not, return false )) ) //Otherwise, segue away true ) //Pick the roles that match the given categories rolesFor := method(categories, roles select(i, v, v categories containsAll(categories)) ) //Set up role characters based on previous scene's roles segueFromInAt := method(lastScene, h, t, //You could do other stuff in this method too, like set fluents lastScene roles foreach(i,v, rolesFor(v categories) foreach(i2, v2, v2 characters appendSeq(v characters) ) ) ) ) //And later, in our quest generator... SceneOne := Scene clone do( setRoles(list(DonutAsker, DonutProvider)) setGoals(DonutAsker goals) ) SceneTwo := Scene clone do( setRoles(list(DonutReceiver, DonutGiver)) setGoals(DonutGiver goals) setPreconditions(list( block(scene, oldScene, h, t, //Ensure that the question has been asked. //For now, we only have these two scenes, so it doesn't matter, //but this way shows how scene chaining like this might be done. //In other words, "ensure that the goal which was accomplished was //the ask-for-things goal". askGoal := oldScene rolesFor("consumer") first goals first h fluentValue("satisfied", askGoal, t) ) )) )
This might be all that's needed of a Scene. The important part is that Scenes are fairly self-sufficient - they look into the previous scene to see if they can execute, and to grab the characters they need for their roles. Note that the item involved between these scenes is encoded into the goals themselves.
At this point, one might be justified in saying that it's sleight of hand to simply provide these "And later..." definitions, but in the next essay, I'll show how these might be generated.
Quest and Answer
A Quest, recall, is a series of Scenes. Since Scenes handle their own entrances, and the change of currentScene over time isn't really important to track, Quest can be fairly simple:
Quest := Object clone do( //All the Scenes that might show up in this Quest. possibleScenes ::= list() //All the Scenes that count as endpoints for the Quest. finaleScenes ::= list() //The currently active Scene. currentScene ::= nil //The Scenes that have been played. completedScenes ::= list() //Housekeeping. init := method( setCompletedScenes(list()) self ) //Pick and segue to the next Scene. chooseNextSceneInAt := method(h, t, //Bail if we're in the middle of a Scene. if(currentScene satisfiedInAt(h, t) not, return false) if(satisfiedInAt(h, t), return true ) available := possibleScenes select(i,v, completedScenes contains(v) not and( canSegueFromInAt(currentScene, h, t) ) ) completedScenes append(currentScene) //We can choose randomly here, but a real implementation //would be smarter and replace the simple 'canSegue...' with //some numerical value for how good a match it is. next = available anyOne next segueFromInAt(currentScene, h, t) setCurrentScene(next) true ) satisfiedInAt := method(h, t, finaleScenes contains(currentScene) and( currentScene satisfiedInAt(h, t) ) ) ) //And later, in our quest generator... DonutQuest := Quest clone do( setPossibleScenes(list(SceneOne, SceneTwo)) setFinaleScenes(list(SceneTwo)) setCurrentScene(SceneOne) ) //And somewhere in our game code... while( //... //Try picking the next Scene. //If it returns true, something happened. if(currentQuest chooseNextSceneInAt(h, t), //Is the quest over? if(currentQuest satisfiedInAt(h, t), //The quest is over! startNextQuest() , //We must have a new scene... handleNextScene() ) ) //... )
I say that the currentScene isn't important to track historically because what's important about a Scene happening in a Quest isn't the Scene itself, but the Fluents that are set as the Scene transpires. Also, the stuff in the "game code" section above could be much nicer and written much differently - I just present it as a naive approach to using this kind of data.
So where does Herodotus really come in? That would be in the Character memories, Goal tracking, quest histories, next-Scene picking, Scene conclusion, and so on. Storing this information in Herodotus makes it a lot easier to write fancier quest generators, design better NPC simulations, and provide ays for Character actions to materially change the game world.
In the next essay, we'll look at how to generate these Donut quests and we'll make a simple text-based adventure game using Thucydides.
Thanks to Rboehme and the other participants in the questgen project.
Labels: example, herodotus, theory, thucydides
Joe Osborn 2008-02-04
0 Comments:
Post a Comment
<< Home