Every story lives first in the space between possibility and form, waiting for rules to shape it. Through Answer Set Programming (ASP), we'll turn logic into narrative, where each constraint becomes a brushstroke in a living world.
Photo Credit: Rob Grzywinski Every story begins in a fog of infinite possibility, waiting for a single decision to shape it. Interactive fiction thrives on this process, blending storytelling with interactivity to create worlds that players explore and shape. For many, Roger Firth’s Cloak of Darkness is the “Hello, World!” of Interactive Fiction — a compact yet nuanced challenge that highlights the interplay of movement, objects, and constraints. Its minimalism makes it an ideal starting point for creators and a perfect canvas for exploring how Answer Set Programming (ASP) amplifies the creative process.ASP mirrors the way creativity unfolds: it invites you to carve clarity from complexity by defining constraints, iterating, and refining. In this guide, we’ll recreate Cloak of Darkness using ASP, turning its elegant simplicity into a dynamic, playable world. Along the way, we’ll explore how ASP helps creators think deeply about their decisions, embrace constraints, and let the story emerge.Let’s step into the fog, guided by the shadows of the Cloak of Darkness, and see what takes form. For those interested in the technical underpinnings of what follows, we've provided a companion piece, "Worlds within Worlds: The Hidden Mechanics", that explores the deeper implementation patterns and architectural decisions. But for now, let's focus on the creative journey itself.
Step 1: The Foyer
Every story begins somewhere. For ours, it’s a grand, red-and-gold foyer, glittering with chandeliers. This is where your player takes their first step, and where you, the creator, take yours. Let’s build it.
The World’s First Facts
In ASP, facts are the foundation of your world — the immutable truths. Let’s start with the basics:
location(foyer) .
person(character) .
at_locationΔ(character, foyer, 0) .
Here’s what these lines do:
location(foyer). declares the foyer as part of the world.
person(character). establishes the player as a character who will explore the world.
at_locationΔ(character, foyer, 0). places the character in the foyer at the start of time, instant 0.
Already, you’ve carved a tiny shape out of the fog. You’ve created a place, a person, and a moment. Lets run this:
{
i
at_locationΔ/3
0
character
foyer
location(foyer)person(character)}
We’ve got an empty world — but it’s a start.
Step 2: Movement Mechanics
The foyer is grand, yes, but static — a snapshot waiting for motion. Now, let’s animate it. Before the player can explore, we must establish the rules that govern this world: things stay put unless acted upon, and movement follows rules. These are the laws of your universe, the scaffolding that makes everything else possible.
Inertia: The First Law of Staying Put
In ASP, inertia ensures that facts persist unless explicitly changed. For instance, if the player is in the foyer at time instant 0 and doesn’t move, they should still be in the foyer at time instant 1. Let’s encode that:
at_locationΔ(A, L, I+1) ←
at_locationΔ(A, L, I),
not not at_locationΔ(A, L, I+1),
next(I) .
Here’s what’s happening:
Persistence: If something (like the player) is at a location at time I, it will stay there at I+1 unless there’s evidence to the contrary.
Flexibility: This rule doesn’t lock anything in place forever. It creates room for change while ensuring things don’t randomly disappear.
Uniqueness — One Place at a Time
Anything can only be in one location at any given time. Let’s enforce that using (56) for convenience:
These facts establish the valid paths the player can take: south to the bar or west to the cloakroom.While we could get fancy and define cardinal directions abstractly (making "north" and "south" natural opposites), sometimes the direct approach is more clear. In (5), we've simply stated each connection and its reverse explicitly. We provided more information in Worlds within Worlds: The Hidden Mechanics if you're interested.
Movement as a Conversation
Movement isn’t just a mechanical action; it’s a dialogue. The player declares their intent (“I want to go west!”), and the world responds. Let’s write the rules that make this happen:
Movement Rule: The first rule listens for the player’s intent (moveρΩ/2) and checks:
Where the character currently is (L0).
Whether there’s a valid direction (D) leading to another location (L).If all conditions are true, the system emits moveΩ/3, allowing the movement.
Updating Location: The second rule ensures that if the player moves, their location is updated at the next instant (I+1).
Preventing the Impossible
What if the player tries to move east, where there’s no exit? The world needs to respond:
output((invalid, I), ("There doesn't appear to be an exit to the ", D)) ←
moveρΩ(D, I),
at_locationΔ(character, L, I),
not direction(L, D, _) .
This rule catches invalid moves and provides feedback instead of leaving the player in the dark. Now, the system doesn’t just enforce the rules — it engages with the player.
Adding a Twist: The Foyer’s Secret
Not every rule is about mechanics. Sometimes, it’s about atmosphere. Let’s make the foyer a bit cheeky. If the player tries to leave to the north, the world responds:
direction(foyer, north, foyer) .
output((valid, I), ("You've only just arrived, and besides, the weather outside",
"seems to be getting worse.")) ←
moveρΩ(north, I),
at_locationΔ(character, foyer, I) .
This does two things:
Blocks the Move: By redirecting the north direction back to the foyer, we ensure the player stays put.
Adds Personality: The message hints that the story lies within, not outside.
Testing What We’ve Built
Let’s test out little universe.
Moving east from the foyer:
moveρΩ(east, 0) .
{
i
moveρΩ/2
0 → 1
east
i
at_locationΔ/3
0
character
foyer
output((invalid,0),("There doesn't appear to be an exit to the ",east))}
Moving south from the foyer to the bar:
moveρΩ(south, 0) .
{
i
moveρΩ/2
0 → 1
south
i
at_locationΔ/3
0
character
foyer
1
character
bar
}
Attempt to move north from the foyer:
moveρΩ(north, 0) .
{
i
moveρΩ/2
0 → 1
north
i
at_locationΔ/3
0
character
foyer
output((valid,0),("You've only just arrived, and besides, the weather outside","seems to be getting worse."))}
Step 3: Adding Objects
Now that the player can move through the world, it’s time to populate it with objects — things they can see, hold, and interact with. Objects bring depth to the world and set the stage for puzzles and storytelling.
Introducing the World’s Things
Let’s start simple: we’ll add the cloak, the hook, and the message. Each of these will play a role in the game’s story.
thing(cloak ; hook ; message) .
These facts declare the objects in the world. Now let’s create the remaining locations:
The cloak starts in the player’s inventory. After all, they’re wearing it — it’s the titular Cloak of Darkness.
The hook is in the cloakroom. It’s waiting for the player to hang the cloak on it.
The message lies in the bar, scrawled in the sawdust.
Already, the world feels more alive. We’ve added objects to interact with and hinted at the story’s goal.
Adding Context: Moveable vs. Fixed Objects
Not all objects are created equal. Some, like the cloak, can be moved. Others, like the hook, remain fixed. Let’s define this distinction:
moveable(cloak) .
This fact establishes the cloak as moveable. The hook and message, by omission, are immovable — they’re part of the environment.
Testing the World’s Objects
Let’s confirm that everything is in place:
{
i
at_locationΔ/3
0
character
foyer
cloak
inventory
hook
cloakroom
message
bar
}
The character is in the foyer, the cloak is in the character's inventory, the hook is in the cloakroom and the message is in the bar.
Step 4: Taking and Dropping Objects
Now that the world has objects, let’s make them interactive. In this step, we’ll let the player take and drop objects, adding depth to the story and laying the groundwork for meaningful puzzles.
Taking Objects: The Power to Hold
The player should be able to pick up moveable objects from their current location. Let’s write the rules for taking:
Intent to Drop: The player specifies their desire to drop an object (dropρΩ/2).
Validation: The system checks if the object is in the player’s inventory and the action isn’t invalidated by other actions.
Update Location: If valid, the object moves from the player’s inventory to the current location.
Catching Invalid Actions
If the player tries to drop something they shouldn’t, we’ll handle it with feedback:
Player Doesn't Have It:
output((invalid, I), ("You don't have a ", T)) ←
dropρΩ(T, I),
not at_locationΔ(T, inventory, I) .
Not the Right Place: The cloak's deep blackness absorbs light, making it essential to hang it in the cloakroom for the bar to be illuminated.
output((invalid, I), ("The ", L, " isn't the best place to leave a smart",
"cloak lying around.")) ←
dropρΩ(cloak, I), at_locationΔ(cloak, inventory, I),
at_locationΔ(character, L, I), L != cloakroom .
Adding Feedback
Feedback transforms mechanics into conversation. Let’s inform the player when they successfully take or drop an object:
When Taking:
output((valid, I), ("You picked up the ", T)) ←
takeΩ(T, _, I) .
When Dropping:
output((valid, I), ("You drop the ", T, " on the floor of the ", L)) ←
dropΩ(T, L, I) .
Testing Taking and Dropping
Let’s test these interactions:
Take the Cloak:
takeρΩ(cloak, 0) .
{
i
takeρΩ/2
0 → 1
cloak
i
at_locationΔ/3
0
character
foyer
cloak
inventory
hook
cloakroom
message
bar
output((invalid,0),("There is no ",cloak," here"))}
Drop the Cloak in the Foyer:
dropρΩ(cloak, 0) .
{
i
at_locationΔ/3
0
character
foyer
cloak
inventory
hook
cloakroom
message
bar
output((invalid,0),("The ",foyer," isn't the best place to leave a smart","cloak lying around."))}
Drop the Cloak in the Cloakroom: For testing specific cases like this, we must explicitly set the initial conditions in the program. This ensures the state of the world matches the scenario being tested, allowing us to verify the behavior reliably.
output((valid,0),("You drop the ",cloak," on the floor of the ",cloakroom))}
Invalid Take:
takeρΩ(hook, 0) .
{
i
takeρΩ/2
0 → 1
hook
i
at_locationΔ/3
0
character
foyer
cloak
inventory
hook
cloakroom
message
bar
output((invalid,0),("There is no ",hook," here"))}
Step 5: Describing the World
Now that the player can move, take, and drop objects, it’s time to add some storytelling flair. Descriptions provide context, atmosphere, and clues, elevating the game from a mechanical exercise to an engaging narrative. In this step, we’ll describe locations, objects, and the player’s inventory.
Describing Locations
Every location in the world should give a description when the player looks around. The describeΩ/2 rule ensures this by checking if the player is at the specified location (L) at the current time instant (I). This way, descriptions are context-aware and only triggered when relevant.
output((valid, I), ("You are standing in a spacious hall, splendidly decorated",
"in red and gold, with glittering chandeliers overhead.",
"The entrance from the street is to the north, and there",
"are doorways south and west.")) ←
describeΩ(foyer, I) .
The Cloakroom
output((valid, I), ("The walls of this small room were clearly once lined with",
"hooks, though now only one remains. The exit is a door to",
"the east.")) ←
describeΩ(cloakroom, I) .
The Bar
The bar’s description depends on whether the cloak is present. If the cloak is in the bar (or in the player’s inventory while they’re in the bar), it’s too dark to see:
output((valid, I), ("It is dark in here.")) ←
describeΩ(bar, I),
within_locationΔ(cloak, bar, I) .
If the cloak isn’t there, the bar becomes visible:
output((valid, I), ("The bar, much rougher than you'd have guessed after the opulence",
"of the foyer to the north, is completely empty. There seems to",
"be some sort of message scrawled in the sawdust on the floor.")) ←
describeΩ(bar, I),
not within_locationΔ(cloak, bar, I) .
Describing Objects
The player can describe their inventory in every location except the bar while the cloak is present, as the cloak absorbs all light and leaves the bar too dark to see. This contextual rule adds depth to the game while tying mechanics to the narrative.
output((valid, I), ("A handsome cloak, of velvet trimmed with satin, and slightly",
"splattered with raindrops. Its blackness is so deep that it",
"almost seems to suck light from the room.")) ←
describeΩ(cloak, I) .
The Hook
If the cloak is hanging on the hook in the cloakroom, the description changes:
output((valid, I), ("It's just a small brass hook, with a velvet cloak hanging on it.")) ←
describeΩ(hook, I), at_locationΔ(character, cloakroom, I),
at_locationΔ(cloak, hook, I) .
If the hook is empty:
output((valid, I), ("It's just a small brass hook.")) ←
describeΩ(hook, I), at_locationΔ(character, cloakroom, I),
not at_locationΔ(cloak, hook, I) .
Describing the Player’s Inventory
The player can describe their inventory in every location except the bar while the cloak is present, as the cloak absorbs all light and leaves the bar too dark to see. This contextual rule adds depth to the game while tying mechanics to the narrative.
describeΩ(inventory, I) ←
describeρΩ(inventory, I),
at_locationΔ(character, L, I), L != bar .
If they have no items:
output((valid, I), ("You have no possessions.")) ←
describeρΩ(inventory, I),
not at_locationΔ(_, inventory, I) .
In any location where the player can describe their inventory, the play can describe items in their inventory.
describeΩ(T, I) ←
describeρΩ(T, I), thing(T), at_locationΔ(T, inventory, I),
at_locationΔ(character, L, I), L != bar .
Testing Descriptions
Let’s test these descriptions to ensure they respond correctly:
Look around the foyer:
describeρΩ(foyer, 0) .
{
i
describeρΩ/2
0 → 1
foyer
i
at_locationΔ/3
0
character
foyer
cloak
inventory
hook
cloakroom
message
bar
output((valid,0),("You are standing in a spacious hall, splendidly decorated","in red and gold, with glittering chandeliers overhead.","The entrance from the street is to the north, and there","are doorways south and west."))}
output((valid,0),("A handsome cloak, of velvet trimmed with satin, and slightly","splattered with raindrops. Its blackness is so deep that it","almost seems to suck light from the room."))}
output((valid,0),"It's just a small brass hook.")}
Describe the bar with the cloak in the inventory:
at_locationΔ(character, bar, 0) .
at_locationΔ(cloak, inventory, 0) .
at_locationΔ(hook, cloakroom, 0) .
at_locationΔ(message, bar, 0) .
describeρΩ(bar, 0) .
{
i
describeρΩ/2
0 → 1
bar
i
at_locationΔ/3
0
character
bar
cloak
inventory
hook
cloakroom
message
bar
output((valid,0),"It is dark in here.")}
Describe the bar when the cloak is not present:
at_locationΔ(character, bar, 0) .
at_locationΔ(hook, cloakroom, 0) .
at_locationΔ(cloak, hook, 0) .
at_locationΔ(message, bar, 0) .
describeρΩ(bar, 0) .
{
i
describeρΩ/2
0 → 1
bar
i
at_locationΔ/3
0
character
bar
cloak
hook
hook
cloakroom
message
bar
output((valid,0),("The bar, much rougher than you'd have guessed after the opulence","of the foyer to the north, is completely empty. There seems to","be some sort of message scrawled in the sawdust on the floor."))}
Step 6: Winning and Losing the Game
Now that the world is interactive and responsive, it’s time to define the game’s ultimate goal: reading the message in the bar without disturbing it. We’ll also define losing conditions and ensure the game provides clear feedback for both outcomes.
Illuminating the Bar
The cloak is central to the game’s mechanics. If the cloak is present in the bar — either directly or within the player’s inventory — the bar remains dark, preventing the player from reading the message. The bar is illuminated only if the cloak is hanging on the hook in the cloakroom.
illuminatedΔ(bar, I) ←
not within_locationΔ(cloak, bar, I), always(I) .
Tracking Disturbances to the Message
The player loses if they disturb the message in the bar. Any action in the bar — such as moving in an invalid direction or dropping objects — counts as a disturbance:
disturbed_messageΔ(I+1) ←
at_locationΔ(character, bar, I),
dropΩ(_, bar, I),
next(I) .
disturbed_messageΔ(I+1) ←
at_locationΔ(character, bar, I),
output((invalid, I), _),
next(I) .
disturbed_messageΔ(I+1) ←
disturbed_messageΔ(I),
next(I) .
This tracks whether the player’s actions have compromised the clarity of the message. If disturbed, the win condition becomes invalid.
Defining Winning and Losing
The player wins if they read the undisturbed message in the bar while it is illuminated:
output((valid, I), ("The message, neatly marked in the sawdust, reads...",
"*** You have won! ***")) ←
describeΩ(message, I),
at_locationΔ(character, bar, I), illuminatedΔ(bar, I),
not disturbed_messageΔ(I) .
The player loses if they read the message after it has been disturbed:
output((valid, I), ("The message has been carelessly trampled, making it difficult to read.",
"You can just distinguish the words...",
"*** You have lost ***")) ←
describeΩ(message, I),
at_locationΔ(character, bar, I), illuminatedΔ(bar, I),
disturbed_messageΔ(I) .
Testing the Endgame
Let’s test the win and loss conditions to ensure they behave as expected:
Winning the Game
Winning the Game: Describe the message when the cloak is not present:
at_locationΔ(character, bar, 0) .
at_locationΔ(hook, cloakroom, 0) .
at_locationΔ(cloak, hook, 0) .
at_locationΔ(message, bar, 0) .
describeρΩ(message, 0) .
{
i
describeρΩ/2
0 → 1
message
i
at_locationΔ/3
0
character
bar
cloak
hook
hook
cloakroom
message
bar
output((valid,0),("The message, neatly marked in the sawdust, reads...","*** You have won! ***"))}
Losing the Game: Describe the message when the cloak is not present:
("The ",bar," isn't the best place to leave a smart","cloak lying around.")
(valid,3)
("You drop the ",cloak," on the floor of the ",cloakroom)
(valid,6)
("The message has been carelessly trampled, making it difficult to read.","You can just distinguish the words...","*** You have lost ***")
}
Conclusion: From Fog to Form
We started with a blank page — or maybe it wasn’t blank at all, but full of infinite possibilities. Through each step, we carved clarity from the chaos, shaping a tiny world where a cloak, a hook, and a message tell a story of constraint and creativity.The journey wasn’t just about building a game; it was about exploring the process of creation itself. We began with immutable truths, layered in motion and interaction, and refined the mechanics of storytelling. The constraints we introduced — whether they guided movement, illuminated the bar, or determined victory — weren’t limitations. They were the scaffolding that gave the story structure and meaning.Through Answer Set Programming (ASP), we mirrored the messy, iterative nature of creativity. We didn’t demand perfection from the start. Instead, we made decisions, tested them, learned from what pushed back, and adjusted. In the end, we didn’t just solve a problem; we created a world.The beauty of this process is that it doesn’t end here. This world, like any creative work, is a foundation for further discovery. You can expand it, adding new locations, objects, or puzzles. You can deepen it, crafting richer interactions and more intricate mechanics. Or you can take what you’ve learned and apply it to something entirely new.Because whether you’re writing stories, composing music, or designing software, creativity is always the same: turning nothing — or everything — into something. Something you can push against, refine, and make your own.Congratulations on crafting your world. Now, go make another!
Playing in the World We Built
Every creative act is a conversation between possibility and reality — between what might be and what is. Now, after carefully constructing our world's rules and rhythms, we get to step inside and play. This isn't just testing; it's the moment where our carefully crafted systems come alive through interaction.The interactive fiction below represents all we've built together: the movement mechanics, the object interactions, the descriptive flourishes, all working in concert to create an experience. Try it out — move around, examine things, see how the pieces fit together. Notice how each interaction, each response, emerges from the rules we've established, yet feels natural, almost inevitable. This playable version brings us full circle — from abstract rules to concrete experience, from possibility to reality. It reminds us that creativity isn't just about building systems; it's about building systems that create meaningful experiences. Whether you're crafting interactive fiction, writing a novel, or developing software, the goal is the same: to create something that feels alive in the hands of its users.What strikes me most is how the constraints we established — the rules about movement, object interaction, and state changes — don't limit the experience but rather shape it into something coherent and meaningful. Like a sonnet's fourteen lines or a haiku's syllable count, these constraints create the very space where meaning can emerge.
Appendix
Convenience Predicates
People (person/1) and things (thing/1) are separate atoms but there are many times in which one needs to refer to anything
String edit distance through the lens of AI planning using Answer Set Programming (ASP). Rather than diving into yet another dynamic programming solution, we'll explore transformation sequences as planning problems.16 January 2025