Project 1: AI Story Generator
Main Code Jam Blog Post: March Code Jam
Project Length: 1 Day
GitHub Repo: https://github.com/mjzitek/text-adventure
For my first project, I'm going to do a "AI Story Generator". The idea is to have kind of a choose your own adventure type of story where the user can ask a few questions and a story will begin. As the story progresses they will have to pick between some options that will guide the story.
Since this is the first project in this March Code Jam let me take a minute to talk about the rules. First, projects will be 1, 2, or 3 "days" where a "day" is defined as approximately 8-hours of work. This could me that it will take place over multiple wake/sleep periods, but the general goal is to have it take place during one calendar day. I will blog about my progress and at the end of the 8 hours of work the project will be marked completed and if I judge it as successful or not. I'm also planning on putting up at least some of the projects on this website or somewhere else for people to check out. (I need to figure out what I want to do as far as LLM access.)
OK...let's go.
Hour One - Beginnings
The first thing I like to do when starting a project is brainstorm it in something like ChatGPT.
I am creating a text based adventure game that is LLM powered. Help me brainstorm a plan. Do not create any code. The idea is to have the LLM ask a few basic questions that will set up the theme and overall story. From there the LLM will generate the story and guide the user. Periodically the LLM will ask the user questions to help guide the story.
I decided I wanted to lock the setting to a post-apocalyptic story and have the initial questions be about the character the user wants. I've also decided to have a memory system to keep track of NPCs, player memories, etc.
While I could probably go on with ChatGPT and use most of our 8-hours on just the planning, we need to get started. The last thing I ask ChatGPT in these type of sessions is to write up a type of planning doc. I use some variation of this:
Now write this up in a document. Imagine you are a project planner laying out the project for the software developers. Do not include timelines. Explain the plan in enough detail so the devs can get started.
Basically I want something that I can turn into documentation, especially when I am using some coding assistant like Cursor to help develop this. For a lot of these Code Jam projects the idea is for me to actually, you know, develop, but I want to use the coding assistants to help me. For some of the projects though I do want to see how far I can get using the coding assistant. For this one I'm going to use a mix. Even if you are planning on doing the majority of the coding yourself, these coding assistants can be great for getting the project started.
Next I take the output of that plan from ChatGPT and create a "project-doc.md" in Cursor. I then start planning the project out.
Cursor gave me a nice plan and asked me a few questions
Since this going to be a fairly short project I don't want to spend a lot of time on a complex game mechanic. I next asked Cursor to start generating the app structure and files so I can get started.
For this project I'm mainly focusing on the story generation with the LLM so I'm going to spend a good amount of time on the prompts rather than worry too much on the game mechanics so I'll let Cursor handle some of that.
OK, Cursor has created the initial app and I've got everything set up to run. Let's see what we have.
Not bad! Looks like my work is done! But not so fast...what am I going to do with the rest of the day? Just kidding...I'm actually going to remove some of the things that Cursor created and redo them myself.
As you can see it did a pretty good job at creating the file and prompts but I'm going to take those out. I've left the files themselves though. I also went through the other files looking for prompts and removed most of the text for them. I also took out the lists for the character background and traits.
Now we still have the engine but can't get far, so let's get to work!
Hour Two - Character Creation
I began working on recreating the backgrounds and traits. I created a list of traits myself and then asked ChatGPT for some ideas. I wanted to make make a wide range of background jobs and had a little humor to it. A few I came up with: "Beet Farmer", "Manager of a struggling paper company", "Theoretical Physicist".
For the traits I initially asked ChatGPT for a list and it gave me a good idea....have a list for positive, neutral, and negative traits.
Your character's traits are: Resourceful, Lone Wolf, Hot-Tempered
Generating your character description...
Great! Now I have a name and traits. Next I need to create the prompt for the character generation. For this I'm using OpenAI's Chat Playground, to keep it fairly simple. While I could just use ChatGPT, I want to make the output as close to what it will be like for the actual app, rather than adding the layers of system prompts that ChatGPT has.
I like the idea of the full character name, appearance, and backstory. It's probably giving me more text than I need though so we'll have to tweak that.
Awesome! Now I'm cooking with the fire that is burning throughout this post-apocalyptic world.
Hour 3 - Story Time
So now we need to generate the story opening scenario. Cursor had already created a "story_generation.txt" for the prompt but of course I cleared that out so I'll need to recreate it.
Actually I'm going to change things up a bit. It looks like it was using that prompt in the generate_story_segment()
method but I want a seperate one to get the story started so I'm creating a "story_premise.txt" and an new method.
While I'm trying to minimize using Cursor's AI for the rest of the project, it can be helpful you you get stuck and need a second pair of eyes.
In this case I stupiditly forgot to save the story_premise.txt file so it was empty. My code was working...I was just dumb. One thing to note, orginally Cursor had changed some code and added text to the fallback.
print(template_path)
if template_path and Path(template_path).exists():
with open(template_path, 'r') as f:
template = f.read()
else:
template = ""
This was something that Cursor had written when it created the code. I have just left it in for now but not actually using it really.
After double checking that I had saved the text file, (I hadn't,) I tried running it again and got a nice backstory to start off our adventure. The next thing I need to work on is the story generation. For the prompt I'm using a combo of typing it out but also using ChatGPT and the OpenAI playground to help me text.
For ChatGPT I'm asking it to generate the filled out message that I can paste into the Playground to test out. I'm mainly focusing on the questions piece.
Not bad but could use some tweaking. One of the challenges when working with LLMs in any application is you don't always know what the response format is going to be when dealing with text output. Of course with OpenAI's JSON output and structured output this makes this less of a problem, but for now I'm just returning plain text. For this application it probably doesn't matter since we are mainly dealing with text input and letting the LLM deal with it, things like how "Choices" is formatted will bug me if it for instance, bold one time, and not bold the next.
Hour 4 - Continuing the work
Not much to report for this hour...I'm mainly working on running the backstory and setting up the changes to the story segment generation. While the code was already there, I'm of course making some changes as I started to outline in hour three.
---
Story Premise:
{story_premise}
--
Main Character:
{character_info}
---
Summary of Story:
{summary}
---
NPC Relationships
{npc_relationships}
---
Recent Events:
{recent_events}
---
Player Response:
{player_response}
---
Above are the current info fields I'm trying to pull in to the prompt. We will see how this works out...
I added some logic to allow '0' for random choices for the intitial character creation.
We have a basic story segment now. One thing I don't like is the raw markdown being displayed so I made a modification so that we have some more rich text outputted. For this I turned back to the Cursor Agent and asked it to format the text.
Not perfect but getting there
Hour 5 - Story Cycles
So I was stuck again on this error when processing the action (choice from player):
An error occurred: 'PosixPath' object is not subscriptable
It turned out because I wasn't passing enough parameters to generate_story_segment()
in LLMCLient
. The template path was going to a different field and causing the error. The field I was missing was "story_premise".
def generate_story_segment(self, story_premise, character_description, current_situation,
recent_events, npc_relationships, player_action,
template_path=None):
The story premise is what I am generating when we first start up the game after character creation, but I wasn't storing it outside of start_game()
where it was being created, so (with the help again of Cursor) added it to the database. So now I have:
story_premise = game_state.get('story_premise', '')
...or at least I thought it was finished...
An error occurred: no such column: story_premise
Ugh...ok...umm...Cursor, we never created that column in the db. Also, stop making more changes than I asked for.
In all seriousness though, one problem with these coding assistants is they being too helpful sometimes.
They can often make a bunch of "fixes" that hopelessly break your app sometimes. Cursor has "checkpoints" that can help you revert back to a working state. Frequent source control checkins can help with this too, just like good old fashioned development without an AI. (I'm pretty bad at remembering to do this though.)
So at this point I have something that sort of works. It's generating story segments but I'm not sure the event history is working and I don't think it's actually processing the players choices, so that's the next piece to work on. I also want to do some formating on the prompt so it's easier for the LLM to see the sections.
---
<STORY PREMISE>
{story_premise}
</STORY PREMISE>
--
<MAIN CHARACTER>
{character_info}
</MAIN CHARACTER>
---
<SUMMARY OF STORY>
{summary}
</SUMMARY OF STORY>
---
<NPC RELATIONSHIPS>
{npc_relationships}
</NPC RELATIONSHIPS>
---
<RECENT EVENTS>
{recent_events}
</RECENT EVENTS>
---
<PLAYER RESPONSE>
{player_response}
</PLAYER RESPONSE>
I'm not sure how much this will help the LLM, but if nothing else it'll help me when I'm debugging.
Hour 6 - Dialing it in
As I suspected the engine isn't passing the information I want, specifically the player choice.
It is passing the recent events, although in reverse order....so I'll fix that first.
It looks like that was an easy fix...just a change in the order from query.
The number I'm choosing is coming across but there is no context in the prompt info that I see.
<RECENT EVENTS>
- Round 1: Mike "Puzzle" Anderson finds himself standing at the edge of a crumbling cliff overlooking the turbulent sea. The salty breeze carries with it the remnants of the past, the echoes of laughter from long-gone tourists mingling with the cries of gulls that now call this desolate place home. He scans the landscape, noting the skeletal remains of Driftwood Bay below, its once-bustling boardwalk now a rotting skeleton, reclaimed by nature. As he contemplates his next move, a group of survivors from The Collective approaches, their expressions weary but determined. They need his help to fortify their community against the looming threat of the Marauders, who have been spotted nearby. (Player: begin the adventure)
- Round 2: Mike "Puzzle" Anderson stands at the edge of the cliff, the salty wind brushing against his face as the survivors from The Collective gather around him. Their leader, a woman named Elara, steps forward, her eyes reflecting both determination and fear. "We need your expertise, Puzzle. The Marauders won\'t just leave us alone. If we build this escape room trap, we can outsmart them and protect our people." The urgency in her voice stirs something deep within Mike. He feels a rush of excitement at the prospect of crafting a puzzle that could save lives, but he knows the risks involved. (Player: 1)
- Round 3: Mike "Puzzle" Anderson looks into Elara\'s determined eyes, feeling the weight of the decision before him. The air is thick with tension as the other survivors await his response, their faces a mixture of hope and anxiety. He knows that if they can create an escape room trap, they might just outsmart the Marauders and protect their fragile community. But the reality of their situation looms large; failure could mean dire consequences. With a deep breath, he contemplates the best way to proceed, weighing the risks and the potential for creativity that could turn the tide in their favor. (Player: 1)
</RECENT EVENTS>
---
<PLAYER RESPONSE>
1
</PLAYER RESPONSE>
I noticed the summary of story and the most recent event is the same text so I'll work on that. I started making some changes in the code then turned it over to Cursor to finish it. I mainly did this just to save some time.
It's getting closer to what I want, but there is still an "off by one" issue where the "Player Choice" is showing the one from the previous event so that will need to be fixed. The current choice is showing up correctly though.
Hour 7 - Choices, Responses, and Hockey
OK, hockey has nothing to do with this project but one of my teams is playing right now so I'm going to watch some of the game while I work.
So back to the issue of the Player Choice being off...
The issue has to do with how the app is recording the events in the database. Whenever the player makes a choice, the method process_action()
is called that has a parameter of action
. That action gets recorded after we send everything to the LLM to generate the next story segment. To fix it, there is a new update_previous_action()
method. Let's see how that works now.
Looking better! Now to see if the LLM is responding to the player's choices correctly.
**Choices:**
1. Do you decide to venture out tonight to search for the Phoenix Car, despite the risk of encountering the Black Vultures?
2. Do you gather a small group of trusted scavengers to scout the area first and assess the situation?
3. Do you choose to stay within the safety of Junkyard Haven, focusing instead on fortifying defenses against potential raids?
Player Choice: 1
**Round 2:**
Mike "Wheeler" Tamsin adjusts his oversized goggles and tightens the straps of his scavenger\'s pack, feeling the familiar surge of adrenaline coursing through him. He knows the risks of venturing out into the night, especially with the Black Vultures prowling the ruins, but the lure of the Phoenix Car ignites a fire in his heart. As he steps beyond the makeshift barriers of Junkyard Haven, the whispers of the winds seem to guide him, echoing tales of lost treasures and hidden paths. The moonlight bathes the landscape in an eerie glow, casting long shadows over the remnants of a world that once thrived.
So far so good...
**Choices:**
1. Do you confront the figures, hoping they might have information about the Phoenix Car?
2. Do you attempt to sneak around them and continue your search in the opposite direction?
3. Do you create a distraction to draw their attention away while you investigate the garage?
Player Choice: 2
Mike crouches low behind the overturned bus, his breath steadying as he assesses the cloaked figures rummaging through the remnants of the garage. Their shadows flicker in the moonlight, and though he can’t make out their faces, he can sense the tension in the air. The last thing he wants is a confrontation with the Black Vultures, but the urge to find any clue about the Phoenix Car is overwhelming. With the rusted debris crunching softly under his feet, he grips the strap of his pack tighter, deciding to avoid them for now.
Looks like it's working. I'm going to run it through some more rounds and see where this story progresses.
After several rounds I've spotted an issue. I'm on round 10, but the prompt only shows events 1 - 5. The story summary seems ok though with my story progress, so maybe the code isn't properly adding the events. I need to add a proper limit in anyway so it just adds the last X number of events.
def get_recent_events(self, game_id, limit=5):
"""Get recent events from the game history."""
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM events WHERE game_id = ? ORDER BY round ASC LIMIT ?",
(game_id, limit)
)
events = [dict(row) for row in cursor.fetchall()]
conn.close()
return events
Aaaandd...there we go. In db.py
the SQL query is getting the last 5 in assending order. And if you recall earlier this was DESC before but we changed it so the events were outputting in the correct order in the prompt. Oops.
At least there was already a limit so I didn't need to worry about that. I did change it from 5 to 10 though. As far as the ordering, I changed the SQL query ORDER BY back to DESC and updated the code in the LLM methods that list out the recent events for the prompt.
Hour 8 - The Final Countdown
Well as of right now the basic functionality appears to be working, so it's time to look back on my orginal plan and see what I can get finished in the last hour.
- Story Length Selection - Nope
- Puzzles and Challenges - Nope
- Rounds System (Chapters) - Nope
- Memory and Consequences - In theory maybe, but not explictly implemented.
- NPCs - Not really. The LLM could generate them as part of the story, but we don't have an real mechanism to create and interact with them throughout the game.
- Inventory - Similar to the NPCs, there is some code to extract this info from the LLM generated text, but not really specific code to handle an inventory.
- Commands - There is some code in the app for thingsk like checking inventory, viewing journal, and seeing what NPCs we've met, but no real way to access them right now or using them.
I wanted to add some inventory to the text returned from the LLM so I added this line to the instructions:
- For "Items Found" decide if the player found something they can add to their inventory. They don't have to always find something. If they do it should be relevant to the current story segment.
A few responses:
Items Found
• A makeshift slingshot crafted from a sturdy branch and a piece of rubber, cleverly hidden among the crates.
Items Found
• A stash of canned goods
• A few basic medical supplies (bandages, antiseptic)
• A rusted bicycle
Items Found
• A weathered map with marked locations
Not bad but I still don't really have any mechanism to do anything with the items, other than they'll be part of the events that are in the prompt.
The code orginally generated by Cursor does have this method but it's more meant to just do string matching in the body of the text.
def extract_items_from_text(self, text, game_id):
"""Extract potential items from story text and add them to inventory."""
# This is a simple implementation that could be enhanced with NLP
# For now, we'll look for common item indicators in the text
# Get current inventory
current_inventory = self.get_inventory(game_id)
# Look for phrases like "you found a [item]" or "you picked up [item]"
item_indicators = [
"found a ", "found an ", "picked up a ", "picked up an ",
"discovered a ", "discovered an ", "obtained a ", "obtained an ",
"received a ", "received an ", "given a ", "given an "
At this point, with more time, it would be good to implement the structured output or JSON, but for now I'll leave it as is since I'm quickly running out of time.
Another thing I wanted to do is add an option to try to give text input back to the story rather than just one of the choices. I was going to try to alter the code to accept something in a addition to 1, 2, or 3 and then give me a prompt to enter text, but then I realized that the current code isn't specifcally looking for one of those options but rather taking the input and sending it to the LLM, sooo.....
Choices:
1. Do you try to eavesdrop on the Traders, hoping to gather information about their plans and resources?
2. Do you attempt to sneak out the back of the warehouse while they are distracted, carrying the supplies with you?
3. Do you decide to make your presence known and approach the Traders, aiming to negotiate for a partnership or trade?
> Take a nap
As Mike crouched low behind the crates, exhaustion washed over him like a wave. The events of the past few days had taken a toll, and the warehouse, filled with the remnants of a forgotten world, beckoned him into a moment of solace. Closing his eyes for just a moment, he let the sounds of the Traders outside fade away, succumbing to a brief nap amidst the chaos.
LOL...that worked. Of course I'm not sure that's really what I want, at least not with some guardrails. How would the LLM react if I gave it something totally out of bounds for the story.
Choices:
1. Do you try to sneak up and grab the map from the Trader, hoping to avoid detection?
2. Do you decide to confront the Traders, using the map as leverage to negotiate a deal?
3. Do you quietly gather the supplies you found and prepare to leave the warehouse undetected, taking the map with you?
> Jump into a spaceship and fly away
Mike rubbed his eyes, still disoriented from his unexpected nap, as the vision of a spaceship flickered through his imagination—a whimsical escape from the harsh realities of this post-apocalyptic world. But reality came crashing back as he remembered the urgency of the moment. The weathered map lay in his hands, revealing potential supply caches that could change the fate of his community. However, the Traders outside were still nearby, and his time was running out.
OK, that was actually better than I had expected. What if I asked a question?
Choices:
1. Do you attempt to sneak out the back with the map and supplies, hoping to avoid detection by the Traders?
2. Do you confront the Traders, using the map to negotiate a trade for safe passage and additional supplies?
3. Do you create a distraction using the slingshot, drawing the Traders’ attention away while you make your escape?
> Tell me about my family
Mike's thoughts drifted back to his family, memories of laughter and warmth flooding his mind like a distant echo. He remembered gathering around the dinner table, sharing stories and dreams of the future—a stark contrast to the cold, harsh reality he now faced. His wife, Sarah, had always been the anchor of their family, her unwavering support giving him the strength to face the challenges of the day. He could almost hear her voice urging him to stay strong, to keep fighting for their community. But now, with resources dwindling and the threat of marauders looming, he felt the weight of responsibility pressing down on him. He could only hope that wherever they were, they were safe and that he could provide them with a future worth living for.
As he held the weathered map tightly, Mike realized that this moment was pivotal. The laughter of the Traders outside faded into the background as he focused on the task ahead. He could either risk everything for the community that had become his new family or take a chance to seek out his own.
It actually does a pretty good job of keeping the story on tract steering it back to what was going on before I tried to derail it.
All that being said, I think I want to lock it down for now and still to choosing between the three questions. The orginal purpose was to allow the player to either enter the number or type out the choice.
Conclusion
Since my time is up, I'm going to wrap this up.
If you want to see outputs from a couple of runs, you can find them here:
How do I think I did? Given the time contraints, OK, but not great. I didn't accomplish most of the goals I wanted to as far as features, but I did get something working the way I wanted from a basic story level.
Side note...you can recreate something similar in ChatGPT with the right prompt and not even have to use a seperate custom application.
To improve on the story line I could probably add code to help direct the stories better. One thing I've found when generating random things is the LLMs tend to still regenerate the same basic things over and over. In these adventures I was noticing the same basic theme repeated on each run. Perhaps adding a basic script and a roll of the dice type of feature to determine outcomes?
There was also no real consequences for the actions...no matter what our player did the game just kept going. This was by design in this case, as I didn't want the game to end because the character died, but it also makes the game a little less entertaining.