Writing a text adventure using functional programming with F#

Putting the fun in functional programming

Frederick Swadling
24 min readNov 11, 2023
Photo by Sigmund on Unsplash

(For reference throughout this article, the full project can be found here: https://github.com/fswadling/TextAdventure)

A bit over a year ago I wrote an article on developing complex long running state machines in F# with the help of custom computational expressions. The basic idea was to flatten asynchronously invoked state machines into a shape that resembles procedural code in the simple cases, and Rx style stream functions for more complex cases. I had intended to develop the idea further, and provide a more concrete example to demonstrate the idea in action, but life got in the way and its been difficult to find the time.

The main problem though, was deciding what to give as an example. In the previous article I spent a lot of time talking about microservice orchestration; the original problem I was facing was coming up with a solution for coordinating complicated email campaigns. The basic idea I started with was: “What if durable functions, but on AWS and Rxy?”, but the solution I came up with and outlined was actually far more general and could be applied to any long running process. Yes, you could use it for managing email campaigns, but that’s boring. I want to solve the real problems in programming. So today I’m going to talk about text adventures.

For those who don’t know, text adventures were a staple of the era when videogames meant shareware on floppy disks on your dads beaten up old PC. There’s a charming simplicity to text adventures; no real-time ray-tracing, no GPUs, no complicated game engine that suddenly decides to hold your game to ransom; just you, your terminal, and your imagination. They made great projects for children, and many Sundays were wasted trying to craft the perfect narrative experience in QBasic …well until it turned out that programming was actually quite hard, and the more immediate gratification of the Nintendo won out.

The idea for this article came to me recently when I was playing Baldur’s Gate 3. The game has a complicated narrative that gives a lot of scope for player agency. There are countless different possibilities for how the story can progress depending on the players choices or on dice roles. This got me thinking how I could write a similar narrative, and I realised that a videogame is basically just another form of a long running process; it’s played over a long period of time over multiple saves, and the various player choices form the input event sequence. Of I course I’m not going to learn the ins and outs of a whole game engine; for the purposes of demonstration a text adventure will more than suffice.

To begin with we will need a top level program that will run the whole process. My program uses the OrchestrationCE code from my previous article that I have just copied in. The basic game loop is as follows:

open System
open OrchestrationCE.Coordination
open Story
open Utilities
open System.Text.RegularExpressions
open FSharp.Json
open System.IO

type TurnResult =
| TurnEvent of Event
| Save of string
| Load of string

let loadGame eventsSoFar gameEvents =
let rec loadGame' eventsSoFar gameEvents =
match gameEvents with
| [] -> eventsSoFar
| event::remainingEvents ->
let { Next = next } = eventsSoFar (Some event)
match next with
| Some next -> loadGame' next remainingEvents
| _ -> eventsSoFar
loadGame' eventsSoFar (List.rev gameEvents)

let rec gameLoop eventsSoFar fullGame game doTurn =
let { Result = actions } = game None

let turnResult = doTurn actions

match turnResult with
| Save filename ->
File.WriteAllText(filename, Json.serialize(eventsSoFar))
Console.WriteLine($"Game saved as {filename}")
gameLoop eventsSoFar fullGame game doTurn
| Load filename ->
let filename = File.ReadAllText(filename)
let events = Json.deserialize<Event list>(filename)
let loadedGame = loadGame fullGame events
gameLoop events fullGame loadedGame doTurn
| TurnEvent event ->
let { Result = _; Next = next } = game (Some event)
match next with
| Some next ->
gameLoop (event::eventsSoFar) fullGame next doTurn
| _ ->
Console.WriteLine "Game over"

The game loop is pretty simple. The game is represented a Coordination type that I described in my previous article. It’s a function that takes an optional event as an argument and returns a collection of results that represent the possible actions the player can take. When the program wants to determine what options are available to the player, it passes None as an argument to the game in order to get the list of possible actions. When a player picks an action, the resulting event is applied as a Some to the game coordination, and the process loops with the resulting next function in the sequence.

The games state, including all the decisions that have been made so far is represented by the sequence of events that have been supplied so far. A player can save and load their game at any time by serialising and deserializing the whole event list. The events themselves are defined in the “story” module. The code that processes the possible actions and gets the players selection is given below:

let rec doTurn possibleActions =
let expositions, choices =
possibleActions
|> List.partition (function | Exposition _ -> true | _ -> false)

if not expositions.IsEmpty then
match expositions with
| Exposition (msg, event)::t ->
Console.WriteLine (msg.ToString())
match event with
| Some e -> TurnEvent e
| _ -> doTurn (t @ choices)
| _ -> doTurn choices
else

let options = List.choose (function | Interactive (msg, event) -> Some (msg, event) | _ -> None) possibleActions

Console.WriteLine "Possible actions:"

do List.iteri (fun i (msg, event) -> Console.WriteLine $"{i}: {msg}") options

Console.WriteLine "Choose an action"

let line = Console.ReadLine()

let optionIndex =
match (System.Int32.TryParse line) with
| true, i when i < options.Length -> Some i
| _ -> None

if optionIndex.IsSome then
let _, event = options.[optionIndex.Value]
TurnEvent event
else

let saveRegexMatch = Regex(@"^save: (.+)$").Match(line)

if saveRegexMatch.Success then
let filename = saveRegexMatch.Groups.[1].Value
Save filename
else

let loadRegexMatch = Regex(@"^load: (.+)$").Match(line)

if loadRegexMatch.Success then
// Extract the filename from the match
let filename = loadRegexMatch.Groups.[1].Value
Load filename
else

Console.WriteLine "Invalid action"
doTurn (List.map Interactive options)

The action type is defined in the Utilities module:

type Action<'TEvent, 'TExposition, 'TInteractive> =
| Exposition of 'TExposition * ('TEvent option)
| Interactive of 'TInteractive * 'TEvent

As you can see the action type is split into into two cases: Expositions that describe simply outputting some text into the console, and Interactives that represent selectable options for the player. The code processes any expositions first, and then prints a list of options to the player and awaits a selection.

So now we have everything we need, we just need to define the coordination that will represent the game itself.

I’d like my story to be inspired by the classic Japanese RPGs I used to play like Final Fantasy. But as any JRPG fan will tell you; to write a classic JRPG story, you need to have come from that particular milieu of 80s boom era Japan, playing imported D&D and trying to escape the rat race of corporate Japanese society. You can’t just write a classic JRPG story.

Or can you? Thankfully it’s the future! ChatGPT, write me a classic 90s style JRPG storyline!

Title: “Echoes of Elaria: The Crystals of Destiny”

Plot:

In the enchanting realm of Elaria, where magic courses through every living thing, a long-forgotten prophecy resurfaces, casting a shadow over the land. The prophecy foretells the existence of four mighty elemental crystals — Earth, Fire, Water, and Air — said to hold the power to shape the world itself.

You, a humble village resident, find your world turned upside down when an unforeseen disaster shatters the ancient Elemental Seal, throwing Elaria into turmoil. As fate’s chosen one, your destiny is to embark on a perilous quest to locate the elemental crystals and prevent the impending catastrophe.

Gathering a group of steadfast companions, each with their own unique qualities:

1. **Aria, the Enigmatic Mage:** Aria, with her mysterious past and deep knowledge of Elaria’s magic, is your magical prodigy. Her spells will be essential to unlocking the crystals’ secrets.

2. **Kai, the Rogue with a Heart of Gold:** Kai, a nimble and quick-witted rogue, is known for his agile combat skills and keen eye for traps. His past is shrouded in secrets, and he’s a master of sneaky tactics.

3. **Lyra, the Fearless Warrior:** Lyra, a valiant and fearless warrior, has a tragic history that fuels her determination to protect Elaria. Her combat prowess with various weapons is unmatched.

Your journey takes you through the majestic forests, treacherous deserts, and mysterious dungeons of Elaria, where you encounter a myriad of challenges, from battling formidable monsters to deciphering enigmatic puzzles. As your adventures unfold, you uncover hidden truths about your lineage, your companions’ pasts, and the origins of the prophesied catastrophe.

Dark forces, led by a malevolent sorcerer named Malachar, are determined to harness the crystals’ power for their sinister designs. Your party’s unity, tactical battles, and mastery of magical spells become crucial as you confront these formidable foes.

With each step, you delve deeper into the mysteries of Elaria’s history, the secrets of the elemental crystals, and the untapped potential within yourself and your companions. The fate of Elaria hangs in the balance, and the choices you make will leave an indelible mark on the world’s destiny.

“Echoes of Elaria: The Crystals of Destiny” is a classic 90s-style JRPG that brings the rich backgrounds and personalities of its characters to life, intertwining them with an epic narrative and a world brimming with enchantment and peril.

Perfect.

In my previous article I extended the idea of Coordinations to Orchestrations; a kind of Coordination that takes an optional event and returns either a result or set of actions to proceed with. The idea is that when you need to get the next event, you pass in None to get the set of actions, and then run those actions to asynchronously get the next event in the chain. My story is just an orchestration, with a step at the end to filter the CircuitBreaker type (bad name I know) to only get the actions (I don’t actually care about the result. In a story the journey is what matters).

So my initial attempt at a story looked something like this:

let tellEvent = function
| Welcome ->
Console.WriteLine "Echoes of Elaria: The Crystals of Destiny"
| AdventureBegins ->
Console.WriteLine "In the enchanting realm of Elaria, where magic courses through every living thing, a long-forgotten prophecy resurfaces, casting a shadow over the land. The prophecy foretells the existence of four mighty elemental crystals – Earth, Fire, Water, and Air – said to hold the power to shape the world itself."
| CalamityOccurs ->
Console.WriteLine "You, a humble village resident, find your world turned upside down when an unforeseen disaster shatters the ancient Elemental Seal, throwing Elaria into turmoil. As fate's chosen one, your destiny is to embark on a perilous quest to locate the elemental crystals and prevent the impending catastrophe."
| CompanionRecruited You ->
Console.WriteLine "You are the chosen one"
| CompanionRecruited Aria ->
Console.WriteLine "Aria, the Enigmatic Mage: Aria, with her mysterious past and deep knowledge of Elaria's magic, is your magical prodigy. Her spells will be essential to unlocking the crystals' secrets"
| CompanionRecruited Kai ->
Console.WriteLine "Kai, the Rogue with a Heart of Gold: Kai, a nimble and quick-witted rogue, is known for his agile combat skills and keen eye for traps. His past is shrouded in secrets, and he's a master of sneaky tactics."
| CompanionRecruited Lara ->
Console.WriteLine "Lyra, the Fearless Warrior: Lyra, a valiant and fearless warrior, has a tragic history that fuels her determination to protect Elaria. Her combat prowess with various weapons is unmatched."
| SearchForCrystalsBegins ->
Console.WriteLine "You and your companions set out on your quest to find the crystals."
| ArrivedAtCrystal crystal ->
Console.WriteLine $"You arrived at the {crystal} crystal"
| CrystalFound crystal ->
Console.WriteLine $"You retrieved the {crystal} crystal"
| TheEnd ->
Console.WriteLine "You saved the world!"

let story = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive StartAdventure ]
(event (function | AdventureBegins -> Some () | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition CalamityOccurs ]
(event (function | CalamityOccurs -> Some () | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Aria) ]
(event (function | CompanionRecruited Aria -> Some Aria | _ -> None))

and! _ =
raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Kai) ]
(event (function | CompanionRecruited Kai -> Some Kai | _ -> None))

and! _ =
raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Lara) ]
(event (function | CompanionRecruited Lara -> Some Lara | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition SearchForCrystalsBegins ]
(event (function | SearchForCrystalsBegins -> Some () | _ -> None))

let! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Fire) ]
(event (function | ArrivedAtCrystal Fire -> Some Fire | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Fire) ]
(event (function | CrystalFound Fire -> Some Fire | _ -> None))

return 0
}

and! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Water) ]
(event (function | ArrivedAtCrystal Water -> Some Water | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Water) ]
(event (function | CrystalFound Water -> Some Water | _ -> None))

return 0
}

and! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Air) ]
(event (function | CrystalFound Air -> Some Air | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition TheEnd ]
(event (function | TheEnd -> Some () | _ -> None))

return 0
}

(The shape of Program.fs has evolved since I wrote this)

It starts with some linear exposition, then allows the player to recruit companions in any order they want, and then once all companions are gathered, the player can go gather crystals in any order they want. And it works! I now have a nonlinear narrative! But its fairly basic, and it only really has one story thread. A proper RPG will often have multiple side stories happening concurrently; I would like to kick off my companions side stories which run in parallel to the main quest, but without necessarily interrupting it.

To achieve this, I will take advantage of the higher order mapping operators I defined in the previous article. In this case I will use the mergeMap. The main story will emit side stories as results, And I will mergeMap these side stories together so that they each run in parallel.

let tellEvent = function
| Welcome ->
Console.WriteLine "Echoes of Elaria: The Crystals of Destiny"
| AdventureBegins ->
Console.WriteLine "In the enchanting realm of Elaria, where magic courses through every living thing, a long-forgotten prophecy resurfaces, casting a shadow over the land. The prophecy foretells the existence of four mighty elemental crystals – Earth, Fire, Water, and Air – said to hold the power to shape the world itself."
| CalamityOccurs ->
Console.WriteLine "You, a humble village resident, find your world turned upside down when an unforeseen disaster shatters the ancient Elemental Seal, throwing Elaria into turmoil. As fate's chosen one, your destiny is to embark on a perilous quest to locate the elemental crystals and prevent the impending catastrophe."
| CompanionRecruited You ->
Console.WriteLine "You are the chosen one"
| CompanionRecruited Aria ->
Console.WriteLine "Aria, the Enigmatic Mage: Aria, with her mysterious past and deep knowledge of Elaria's magic, is your magical prodigy. Her spells will be essential to unlocking the crystals' secrets"
| CompanionRecruited Kai ->
Console.WriteLine "Kai, the Rogue with a Heart of Gold: Kai, a nimble and quick-witted rogue, is known for his agile combat skills and keen eye for traps. His past is shrouded in secrets, and he's a master of sneaky tactics."
| CompanionRecruited Lara ->
Console.WriteLine "Lyra, the Fearless Warrior: Lyra, a valiant and fearless warrior, has a tragic history that fuels her determination to protect Elaria. Her combat prowess with various weapons is unmatched."
| SearchForCrystalsBegins ->
Console.WriteLine "You and your companions set out on your quest to find the crystals."
| ArrivedAtCrystal crystal ->
Console.WriteLine $"You arrived at the {crystal} crystal"
| CrystalFound crystal ->
Console.WriteLine $"You retrieved the {crystal} crystal"
| TheEnd ->
Console.WriteLine "You saved the world!"
| AriaReachesFirstCrystal Earth ->
Console.WriteLine
"""[Aria's eyes widen with awe as she beholds the magnificent crystal. She speaks with a hushed reverence.] "Incredible... This is the Earth Crystal, a vessel of nature's might. Its presence radiates harmony and power, binding the very essence of the land." """
| AriaReachesFirstCrystal Fire ->
Console.WriteLine
"""Aria: [Aria stands before the fiery crystal, her eyes shining with a mixture of fascination and reverence.] "Behold, the Fire Crystal! Its flames dance with a spirit of passion and power. It represents the unyielding force of transformation and the intensity of the soul." """
| AriaReachesFirstCrystal Water ->
Console.WriteLine
"""Aria: [Aria gazes at the Water Crystal, captivated by its soothing, watery glow.] "Here we stand before the Water Crystal, a pool of tranquil power. It embodies the essence of healing and rejuvenation, the calm waters that restore and cleanse." """
| AriaReachesFirstCrystal Air ->
Console.WriteLine
"""Aria: [Aria stands beside the Air Crystal, her hair fluttering in the wind, as she marvels at its ethereal presence.] "The Air Crystal, an embodiment of boundless freedom and endless possibilities. It is the wind's whisper and the breath of life that carries us forward." """
| KaiReachesSecondCrystal Earth ->
Console.WriteLine
"""Kai: [Kai approaches the Earth Crystal, his keen eyes examining the surroundings.] "Well, would you look at that? We've stumbled upon the Earth Crystal. It's solid, sturdy, just like the land it represents." """
| KaiReachesSecondCrystal Fire ->
Console.WriteLine
"""Kai: [Kai approaches the Fire Crystal, his gaze unwavering in the face of the intense flames.] "Ah, the Fire Crystal! It's a lot like life, isn't it? Unpredictable, powerful, and full of surprises." """
| KaiReachesSecondCrystal Water ->
Console.WriteLine
"""Kai: [Kai approaches the Water Crystal, his footsteps echoing softly in the tranquil chamber.] "Look at this, the Water Crystal. It's like a sanctuary, a place of healing and renewal." """
| KaiReachesSecondCrystal Air ->
Console.WriteLine
"""Kai: [Kai stands by the Air Crystal, his hair ruffling in the gentle breeze.] "We've reached the top, and here's the Air Crystal, as free as the wind. It's about adaptability and going with the flow." """
| LaraReachesThirdCrystal Earth ->
Console.WriteLine
"""Lara: [Lyra approaches the Earth Crystal, her eyes gleaming with determination.] "This is the Earth Crystal, a symbol of strength and resilience. It's a reminder that we can overcome any obstacle, no matter how daunting." """
| LaraReachesThirdCrystal Fire ->
Console.WriteLine
"""Lara: [Lyra approaches the Fire Crystal, her gaze steady in the face of the intense flames.] "The Fire Crystal, a beacon of passion and determination. It's a reminder to never give up, no matter how difficult the journey may be." """
| LaraReachesThirdCrystal Water ->
Console.WriteLine
"""Lara: [Lyra approaches the Water Crystal, her footsteps echoing softly in the tranquil chamber.] "The Water Crystal, a symbol of healing and renewal. It's a reminder to take care of ourselves and others, to find peace and balance in our lives." """
| LaraReachesThirdCrystal Air ->
Console.WriteLine
"""Lara: [Lyra stands by the Air Crystal, her hair ruffling in the gentle breeze.] "The Air Crystal, a symbol of freedom and adaptability. It's a reminder to embrace change and go with the flow." """

let ariaStory = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Aria) ]
(event (function | CompanionRecruited Aria -> Some () | _ -> None))

let! firstCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal -> Some crystal | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (AriaReachesFirstCrystal firstCrystal) ]
(event (function | AriaReachesFirstCrystal crystal when crystal = firstCrystal -> Some () | _ -> None))

return 0
}

let kaiStory = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Kai) ]
(event (function | CompanionRecruited Kai -> Some () | _ -> None))

let! firstCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal -> Some crystal | _ -> None))

let! secondCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal when crystal <> firstCrystal -> Some crystal | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (KaiReachesSecondCrystal secondCrystal) ]
(event (function | KaiReachesSecondCrystal crystal when crystal = secondCrystal -> Some () | _ -> None))

return 0
}

let laraStory = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive (RecruitCompainion Lara) ]
(event (function | CompanionRecruited Lara -> Some () | _ -> None))

let! firstCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal -> Some crystal | _ -> None))

let! secondCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal when crystal <> firstCrystal -> Some crystal | _ -> None))

let! thirdCrystal =
raiseToOrchestration
(event (function | ArrivedAtCrystal crystal when crystal <> secondCrystal -> Some crystal | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (LaraReachesThirdCrystal thirdCrystal) ]
(event (function | LaraReachesThirdCrystal crystal when crystal = thirdCrystal -> Some () | _ -> None))

return 0
}

let mainStory = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive StartAdventure ]
(event (function | AdventureBegins -> Some () | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition CalamityOccurs ]
(event (function | CalamityOccurs -> Some () | _ -> None))

yield ariaStory
yield kaiStory
yield laraStory

let! companion1 =
raiseToOrchestration
(event (function | CompanionRecruited x -> Some x| _ -> None))

let! companion2 =
raiseToOrchestration
(event (function | CompanionRecruited x when x <> companion1 -> Some x | _ -> None))

let! companion3 =
raiseToOrchestration
(event (function | CompanionRecruited x when x <> companion2 -> Some x | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition SearchForCrystalsBegins ]
(event (function | SearchForCrystalsBegins -> Some () | _ -> None))

let! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Earth) ]
(event (function | ArrivedAtCrystal Earth -> Some Earth | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Earth) ]
(event (function | CrystalFound Earth -> Some Earth | _ -> None))

return 0
}

and! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Fire) ]
(event (function | ArrivedAtCrystal Fire -> Some Fire | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Fire) ]
(event (function | CrystalFound Fire -> Some Fire | _ -> None))

return 0
}

and! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Water) ]
(event (function | ArrivedAtCrystal Water -> Some Water | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Water) ]
(event (function | CrystalFound Water -> Some Water | _ -> None))

return 0
}

and! _ =
orchestration {
let! _ =
raiseToOrchestrationWithActions
[ Interactive (GoToCrystal Air) ]
(event (function | ArrivedAtCrystal Air -> Some Air | _ -> None))

let! _ =
raiseToOrchestrationWithActions
[ Interactive (RetrieveCrystal Air) ]
(event (function | CrystalFound Air -> Some Air | _ -> None))

return 0
}

do! raiseToOrchestrationWithActions
[ Exposition TheEnd ]
(event (function | TheEnd -> Some () | _ -> None))

return orchestration {
return 0
}
}

let story = mainStory |> Orchestration.mergeMap id

(Writing the dialog became a lot easier when I realised that copilot could do it for me)

What this means is that after recruiting companions, I can go on to collect crystals, and get different dialog from my companions depending on which order I collect them in. Such replay value!

Of course this approach has a problem; it’s quite difficult to respond to events that happen in sub stories within other sub stories or even the main story. At best I can await single events from side stories. Here I want to wait for all of the companions to at least have been recruited before continuing the story, and I manage that by waiting for three different companion recruited events, but this is a pretty awkward way of sharing data. And what if I want to have behaviour that depends on events in side stories that happened in the past? That’s a bit more difficult.

The solution I came up with is to essentially bring back a bit of classic state machine flavour: Transform the event stream to accumulate some state that can be accessed at any time and which is built up across all the sub stories. To facilitate this I need to add some new functions to the Coordination module; a scan function and a skip function:

let scan f seed coordination =
let rec scan' f seed coordination =
coordination
>> fun { Result = result; Next = next } ->
// Need to skip the first element because it is the seed
let result' = result |> List.scan f seed |> List.skip 1
// Need to get the last element of the result to use as the seed for the next coordination
let nextSeed = result' |> List.tryLast |> Option.defaultValue seed
{ Result = result'; Next = Option.map (scan' f nextSeed) next }
coordination
>> fun { Result = result; Next = next } ->
// In order to keep parity with List.scan, we need to include the seed in the result so don't skip first element
// for first step in coordination
let result' = result |> List.scan f seed
// Need to get the last element of the result to use as the seed for the next coordination
let nextSeed = result' |> List.tryLast |> Option.defaultValue seed
{ Result = result'; Next = Option.map (scan' f nextSeed) next }

let rec skip count coordination =
coordination
>> fun { Result = result; Next = next } ->
let remainingCount = count - result.Length
let next' = if remainingCount > 0
then Option.map (skip remainingCount) next
else next

let result' = if remainingCount >= 0
then []
else result |> List.skip count

{ Result = result'; Next = next' }

Coordination.scan is a coordination function that is a counterpart to List.scan. List.scan acts similarly to List.fold, but returns each intermediate step in the aggregation. Coordination.Scan behaves similarly, allowing us to transform an event stream to an aggregated event stream.

Coordination.skip is similarly a counterpart to List.skip. It just skips a given number of results from the start of a coordination.

The function to build the story coordination looks like this:

type EventAndState<'TState, 'TEvent> = 
{ Event : 'TEvent
State : 'TState }

let private stateAccumulator updateState eventAndState = function
| Some e -> { State = updateState eventAndState.State e; Event = Some e }
| None -> { State = eventAndState.State; Event = None }

let baseStoryWithState updateState initState =
event Some
|> Coordination.scan (stateAccumulator updateState) ({ Event = None; State = initState })
|> Coordination.skip 1
|> Coordination.map (function | { Event = Some e; State = state } -> Some { Event = e; State = state } | _ -> None)

So it starts with an infinite coordination using event Some (Note: Some here is used as a chooser function which basically means an identity coordination which returns whatever is given to it). It then uses scan to transform the event sequence to include the aggregated state. It skips one as we don’t want the aggregate seed in our transformed stream, And then finally as the final coordination is an orchestration (i.e. it takes optional values as arguments and uses None to request further instructions), it maps the optional event that was originally passed in so that it is the new event with the aggregated state that is now optional.

The eagle-eyed reviewer of my code would notice that I only need the skip function because of a decision I made that the scan function should behave in a symmetric way to List.scan and return the seed value first before returning aggregated values. Arguably this is unnecessary, but there’s a sort of grand symmetry to functional programming that generally it’s worth preserving (…I think).

The state code for my story is as follows:

type State =
{ CompanionsRecruited: Set<TeamMember>
CrystalsFound: Set<Crystal> }

module State =
let init = { CompanionsRecruited = Set.empty; CrystalsFound = Set.empty }

let updateState state = function
| CrystalFound crystal -> { state with State.CrystalsFound = Set.add crystal state.CrystalsFound }
| AriaEvent AriaInternalEvent.Recruited -> { state with State.CompanionsRecruited = Set.add Aria state.CompanionsRecruited }
| KaiEvent KaiInternalEvent.Recruited -> { state with State.CompanionsRecruited = Set.add Kai state.CompanionsRecruited }
| LaraEvent LaraInternalEvent.Recruited -> { state with State.CompanionsRecruited = Set.add Lara state.CompanionsRecruited }
| _ -> state

Pretty simple. In theory you could even do this kind of state aggregation logic with its own coordination… but I'm getting ahead of myself.

The coordination built so far simply takes an input event stream and transforms it to an aggregated version. These aggregated events are to be used as the arguments of my story orchestration. In order to do this a new function is needed:

let rec compose (coordination2: Coordination<'b, 'c>) (coordination1: Coordination<'a, 'b>): Coordination<'a, 'c> =
let accumulator { Result = accumulated; Next = next } result =
let { Result = result2; Next = next2 } =
next
|> Option.map (fun next -> next result)
|> Option.defaultValue { Result = []; Next = None }
{ Result = accumulated @ result2; Next = next2 }

coordination1
>> fun { Result = result; Next = next } ->
let { Result = result2; Next = next2 } = List.fold accumulator { Result = []; Next = Some coordination2 } result
{ Result = result2;
Next = match next, next2 with
| Some next, Some next2 -> Some (compose next2 next)
| _ -> None }

Now the aggregated events can be fed into the story orchestration. The new story orchestration looks like this:

let rec crystalQuest (crystalsRemaining: Set<Crystal>) =
orchestration {
if (crystalsRemaining.IsEmpty) then
()
else

let goToCrystalActions =
crystalsRemaining
|> Seq.map (fun crystal -> Interactive (GoToCrystal crystal, ArrivedAtCrystal crystal))
|> Seq.toList

let! chosenCrystal =
raiseToOrchestrationWithActions
goToCrystalActions
(event (function | { Event = ArrivedAtCrystal crystal } -> Some crystal | _ -> None))

do! raiseToOrchestrationWithActions
[ Interactive (TakeCrystal chosenCrystal, CrystalFound chosenCrystal) ]
(event (function | { Event = CrystalFound crystal } when crystal = chosenCrystal -> Some () | _ -> None))

let remainingCrystals = Set.remove chosenCrystal crystalsRemaining

do! crystalQuest remainingCrystals

return ()
}

let mainQuest =
orchestration {
do! raiseToOrchestrationWithActions
[ Exposition (Exposition.AdventureBegins, None)
Interactive (Interaction.Continue, Event.AdventureBegins) ]
(event (function | { Event = Event.AdventureBegins } -> Some () | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (Exposition.CalamityOccurs, None)
Interactive (Interaction.Continue, Event.CalamityOccurs) ]
(event (function | { Event = Event.CalamityOccurs } -> Some () | _ -> None))

yield aria
yield kai
yield lara

do! raiseToOrchestration
(event (function | { State = { State.CompanionsRecruited = companionsRecruited } } when companionsRecruited.Count = 3 -> Some () | _ -> None))

do! crystalQuest (Set.ofList [ Earth; Fire; Water; Air ])

return orchestration { return 0 }
}
|> Orchestration.mergeMap id

let story =
baseStoryWithState State.updateState State.init
|> Coordination.compose mainQuest
|> Coordination.collect (function | Break x -> x | _ -> [])

I refactored how the crystal quests work so that once a player chooses to go to a crystal their options are limited to just taking the crystal. Sometimes a good narrative needs to restrict the players choice.

So now the character side stories are kicked off from the main quest and run concurrently. A typical side story looks like this:

type KaiInternalEvent = 
| Recruited
| Introduced
| Continue
| ReachesSecondCrystal of Crystal

type KaiExternalEvent =
| ArrivedAtCrystal of Crystal

type KaiEvent =
| InternalEvent of KaiInternalEvent
| ExternalEvent of KaiExternalEvent

type KaiState =
{ CrystalsFound: Set<Crystal> }

type KaiExposition =
| Introduce
| ReachesSecondCrystal of Crystal

override this.ToString () =
match this with
| Introduce -> "Kai, the Rogue with a Heart of Gold: Kai, a nimble and quick-witted rogue, is known for his agile combat skills and keen eye for traps. His past is shrouded in secrets, and he's a master of sneaky tactics."
| ReachesSecondCrystal Earth -> """Kai: [Kai approaches the Earth Crystal, his keen eyes examining the surroundings.] "Well, would you look at that? We've stumbled upon the Earth Crystal. It's solid, sturdy, just like the land it represents." """
| ReachesSecondCrystal Fire -> """Kai: [Kai approaches the Fire Crystal, his gaze unwavering in the face of the intense flames.] "Ah, the Fire Crystal! It's a lot like life, isn't it? Unpredictable, powerful, and full of surprises." """
| ReachesSecondCrystal Water -> """Kai: [Kai approaches the Water Crystal, his footsteps echoing softly in the tranquil chamber.] "Look at this, the Water Crystal. It's like a sanctuary, a place of healing and renewal." """
| ReachesSecondCrystal Air -> """Kai: [Kai stands by the Air Crystal, his hair ruffling in the gentle breeze.] "We've reached the top, and here's the Air Crystal, as free as the wind. It's about adaptability and going with the flow." """

type KaiInteractive =
| Recruit
| Continue

override this.ToString () =
match this with
| Recruit -> "Recruit Kai."
| Continue -> "Continue."

let kaiStory = orchestration {
do! raiseToOrchestrationWithActions
[ Interactive (KaiInteractive.Recruit, KaiInternalEvent.Recruited) ]
(event (function | { Event = InternalEvent Recruited } -> Some () | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (KaiExposition.Introduce, Some Introduced) ]
(event (function | { Event = InternalEvent Introduced } -> Some () | _ -> None))

let! secondCrystal =
raiseToOrchestration
(event (function | { Event = ExternalEvent (ArrivedAtCrystal crystal); State = { CrystalsFound = crystalsFound } } when crystalsFound.Count = 1 -> Some crystal | _ -> None))

do! raiseToOrchestrationWithActions
[ Exposition (KaiExposition.ReachesSecondCrystal secondCrystal, Some (KaiInternalEvent.ReachesSecondCrystal secondCrystal)) ]
(event (function | { Event = InternalEvent (KaiInternalEvent.ReachesSecondCrystal crystal) } when crystal = secondCrystal -> Some () | _ -> None))

return 0
}

As you can see, the side story has its own encapsulated events, actions and state. The design deliberately isolates the side story from the main story. The main story ultimately owns the state of the side stories, so can know about what is going on in the side story, but the side story is not aware of anything from the calling context. The state and actions in the main story are maintained like so:

type Event = 
| AdventureBegins
| CalamityOccurs
| AriaEvent of AriaInternalEvent
| KaiEvent of KaiInternalEvent
| LaraEvent of LaraInternalEvent
| ArrivedAtCrystal of Crystal
| CrystalFound of Crystal
| TheEnd

type Exposition =
| AdventureBegins
| CalamityOccurs
| AriaExposition of AriaExposition
| KaiExposition of KaiExposition
| LaraExposition of LaraExposition

override this.ToString () =
match this with
| AdventureBegins -> "In the enchanting realm of Elaria, where magic courses through every living thing, a long-forgotten prophecy resurfaces, casting a shadow over the land. The prophecy foretells the existence of four mighty elemental crystals – Earth, Fire, Water, and Air – said to hold the power to shape the world itself."
| CalamityOccurs -> "You, a humble village resident, find your world turned upside down when an unforeseen disaster shatters the ancient Elemental Seal, throwing Elaria into turmoil. As fate's chosen one, your destiny is to embark on a perilous quest to locate the elemental crystals and prevent the impending catastrophe."
| AriaExposition e -> e.ToString ()
| KaiExposition e -> e.ToString ()
| LaraExposition e -> e.ToString ()

type Interaction =
| Continue
| GoToCrystal of Crystal
| TakeCrystal of Crystal
| AriaInteraction of AriaInteractive
| KaiInteraction of KaiInteractive
| LaraInteraction of LaraInteractive

override this.ToString () =
match this with
| Continue -> "Continue"
| GoToCrystal crystal -> $"Go to {crystal} crystal."
| TakeCrystal crystal -> $"Take {crystal} crystal."
| AriaInteraction i -> i.ToString ()
| KaiInteraction i -> i.ToString ()
| LaraInteraction i -> i.ToString ()

In order to integrate the side stories into the main story, some extra plumbing is required. Here is an example:

let getKaiState { State.CrystalsFound = crystalsFound } =
{ KaiState.CrystalsFound = crystalsFound }

let chooseKaiEvent = function
| Some { Event = ArrivedAtCrystal crystal; State = state } ->
Some (Some { Event = KaiEvent.ExternalEvent (KaiExternalEvent.ArrivedAtCrystal crystal); State = getKaiState state })
| Some { Event = KaiEvent e; State = state } ->
Some (Some { Event = KaiEvent.InternalEvent e; State = getKaiState state })
| None -> Some None
| _ -> None

let kai =
event chooseKaiEvent
|> Coordination.compose kaiStory
|> Orchestration.mapBreak
((Action.mapEvent KaiEvent)
>> (Action.mapExposition KaiExposition)
>> (Action.mapInteractive KaiInteraction))

And so now we have everything we need to build the ultimate text adventure! Well admittedly this is still a basic example. You could argue that what I’ve done is an exercise in massive overengineering for what is really quite a simple program, but bear in mind this is more a proof of concept and demonstrative example than a finished piece of production code.

I had quite a lot of fun coming up with this! Hopefully I can find the time to continue this mini-project. The next thing any good RPG needs is thrilling combat!

Part 2 can be found here.

--

--