AI-Powered Text Adventure Game
Interactive fiction, text adventure games where the player types commands and the computer describes what happens next, has been a beloved genre since the days of Zork and Adventure. Traditionally, these games were built by hand: every room, item, puzzle, and narrative branch had to be authored in advance. A game master (human or algorithmic) could only respond in ways the programmer anticipated.
Large language models change this entirely. Instead of scripting every possible scenario, we can give an LLM a system prompt that establishes the world, the rules, and the tone, then lets the model improvise. The player types whatever they want, and the LLM generates a coherent, creative response that respects everything that happened before. There are no hardcoded branches, no “I don’t understand that” dead ends. The story emerges from the conversation.
This chapter builds a complete AI text adventure game in Common Lisp. We provide two backends: Ollama for running an LLM locally (no API keys, no usage costs) and Fireworks.ai for cloud-hosted inference that runs dramatically faster. Both variants share the same conversational architecture and the same chat function — only the model name changes — yet the program delivers an open-ended interactive storytelling experience.
Architecture
The game follows a simple conversation loop:
- System prompt: a text file (
story.txt) that defines the game world and instructs the LLM to act as a game master. - Message history: an accumulating list of role/content pairs representing the full conversation.
- Player input: free-form text typed at a prompt.
- LLM call: the full message history is sent to Ollama’s chat API; the assistant’s reply is displayed.
- Append and repeat: the reply is appended to the history so the LLM remembers past events.
The Ollama integration is handled by a small chat function defined in the game file itself, which hands the multi-turn conversation to litelm:completion and returns the model’s reply. The one dependency is the litelm routing library (in ../litelm), which owns all of the HTTP and JSON work.
The System Prompt
Before looking at the code, here is the data that defines the game world — the contents of story.txt, sent as the initial system message (its lines are broken here to fit the page):
1 You are a text adventure game master. Create an immersive, interactive story
2 for the player. Follow these rules:
3
4 1. Describe the current scene vividly but concisely - include what the player
5 sees, hears, and smells.
6 2. Present 2-4 clear options for what the player can do next at the end of
7 each response.
8 3. Track the player's inventory, health, and progress through the adventure.
9 4. Introduce surprising twists, interesting NPCs, and challenging obstacles.
10 5. Respond to the player's chosen action by advancing the story in a coherent way.
11 6. Maintain internal consistency - remember what has happened before.
12
13 The adventure begins in the Valley of the Troll. The player is a brave
14 adventurer seeking the Golden Chalice, rumored to be hidden deep within
15 the Troll's mountain lair. The Valley is misty and foreboding, with ancient
16 trees casting long shadows. A narrow path winds toward the mountain, while a
17 dark stream gurgles nearby. The Troll is known to collect valuable artifacts
18 but is also said to be open to clever negotiation.
19
20 Begin by describing the opening scene and presenting options for the player.
This prompt does several things at once. It establishes the LLM’s role (game master), the rules of engagement (vivid descriptions, presented options, tracked state), the setting (Valley of the Troll), the goal (find the Golden Chalice), and a hint about gameplay style (negotiation is possible). A well-written system prompt is the difference between a generic chatbot and an immersive game. You can swap out story.txt to create entirely different adventures such as a space station mystery, a noir detective story, a fantasy quest - and this is all without changing a single line of code.
The Game Code
The complete program lives in a single file. We load the litelm routing library, define our package, and implement a small chat function plus two game functions: load-story for reading the system prompt file, and play for the main game loop. There is no HTTP or JSON code in the game itself: litelm:completion builds the request, posts it to Ollama, and hands back the decoded reply.
Here is the full listing of text-adventure-game_ollama.lisp:
1 ;;;; text-adventure-game_ollama.lisp
2 ;;;; Text adventure game using Ollama for AI-driven storytelling.
3 ;;;; Model access goes through the litelm routing library (../litelm), so this
4 ;;;; file holds no HTTP or JSON code of its own.
5 ;;;;
6 ;;;; Usage (LispWorks):
7 ;;;; (load "text-adventure-game_ollama.lisp")
8 ;;;; (text-adventure:play)
9 ;;;;
10 ;;;; Usage (SBCL):
11 ;;;; sbcl --load text-adventure-game_ollama.lisp --eval '(text-adventure:play)'
12 ;;;;
13 ;;;; Requires a local Ollama server with at least one chat model pulled.
14
15 (require 'asdf)
16 (let ((asd (merge-pathnames "../litelm/litelm.asd"
17 (or *load-pathname* *default-pathname-defaults*))))
18 (when (probe-file asd)
19 (asdf:load-asd asd)))
20 (asdf:load-system :litelm)
21
22 (defpackage #:text-adventure
23 (:use #:cl)
24 (:export #:play))
25
26 (in-package #:text-adventure)
27
28 (defvar *ollama-model* "qwen3.5:2b"
29 "Ollama model to play with. litelm needs a \"provider/model\" string, so
30 this may be either \"qwen3.5:2b\" or \"ollama/qwen3.5:2b\".")
31
32 (defvar *ollama-api-base* nil
33 "Optional override for the Ollama base URL passed to litelm. NIL uses
34 litelm's built-in default, http://localhost:11434/v1.")
35
36 (defparameter *story-file*
37 (merge-pathnames "story.txt"
38 (make-pathname :name nil :type nil
39 :defaults (or *load-truename*
40 *default-pathname-defaults*)))
41 "Default system-prompt file: story.txt next to this source file, so the game
42 runs no matter what the REPL's current directory is.")
43
44 (defun ensure-model-name (model)
45 "Prefix MODEL with \"ollama/\" unless it already names a provider."
46 (if (find #\/ model)
47 model
48 (concatenate 'string "ollama/" model)))
49
50 (defun chat (messages &key (model *ollama-model*))
51 "Send the multi-turn MESSAGES (a list of (role content) pairs) to the local
52 Ollama server through litelm and return the assistant's text."
53 (litelm:response-content
54 (litelm:completion (ensure-model-name model)
55 :messages messages
56 :api-base *ollama-api-base*)))
57
58 (defun load-story (filepath)
59 (handler-case
60 (with-open-file (f filepath :direction :input)
61 (let ((content (make-string (file-length f))))
62 (read-sequence content f)
63 content))
64 (file-error (e)
65 (declare (ignore e))
66 (format t "Error: ~a not found.~%" filepath)
67 nil)))
68
69 (defun play (&key (story-file *story-file*) (model *ollama-model*))
70 "Start the text adventure game. Reads story-file as the initial prompt and
71 uses the local Ollama model, through litelm, to generate responses."
72 (let ((story (load-story story-file)))
73 (unless story
74 (return-from play))
75 (let ((messages (list (list :system story))))
76 (format t "~a~%~%" story)
77 (format t "Welcome to the Text Adventure!~%")
78 (format t "Describe what you want to do, or type 'quit' to exit.~%~%")
79 (loop
80 (format t "> ")
81 (force-output)
82 (let ((user-input (string-trim '(#\Space #\Tab #\Newline) (read-line))))
83 (when (member user-input '("quit" "exit") :test #'string-equal)
84 (format t "Goodbye!~%")
85 (return))
86 ;; An empty line simply re-prompts: the body of the turn is skipped
87 ;; and LOOP goes round again. (An earlier version tried to jump with
88 ;; (go :continue), but LOOP defines no such tag, so pressing Enter
89 ;; signalled "attempt to GO to nonexistent tag".)
90 (unless (string= user-input "")
91 (setf messages (append messages (list (list :user user-input))))
92 (let ((response (chat messages :model model)))
93 (when response
94 (format t "~a~%" response)
95 (setf messages (append messages
96 (list (list :assistant response))))))))))))
Loading the System
1 (require 'asdf)
2 (let ((asd (merge-pathnames "../litelm/litelm.asd"
3 (or *load-pathname* *default-pathname-defaults*))))
4 (when (probe-file asd)
5 (asdf:load-asd asd)))
6 (asdf:load-system :litelm)
Because the example is meant to be loaded straight from the repository with load, it registers the sibling ../litelm system itself before loading it. In a project of your own you would instead declare :depends-on (#:litelm) in your .asd file and let ASDF resolve it.
Reading the Story File
1 (defparameter *story-file*
2 (merge-pathnames "story.txt"
3 (make-pathname :name nil :type nil
4 :defaults (or *load-truename*
5 *default-pathname-defaults*)))
6 "Default system-prompt file: story.txt next to this source file, so the game
7 runs no matter what the REPL's current directory is.")
*story-file* anchors the default prompt file to the source file, so the game can be started from any directory — the earlier "story.txt" relative path only worked if you happened to launch Lisp from this directory.
1 (defun load-story (filepath)
2 (handler-case
3 (with-open-file (f filepath :direction :input)
4 (let ((content (make-string (file-length f))))
5 (read-sequence content f)
6 content))
7 (file-error (e)
8 (declare (ignore e))
9 (format t "Error: ~a not found.~%" filepath)
10 nil)))
load-story reads the whole file into a string: it allocates a string exactly the size of the file using file-length, fills it with read-sequence, and wraps the operation in handler-case so that a missing file produces a clean error message instead of dropping into the debugger.
The Main Game Loop
The play function is the heart of the program. Let’s walk through it section by section.
1 (defun play (&key (story-file *story-file*) (model *ollama-model*))
2 "Start the text adventure game. Reads story-file as the initial prompt and
3 uses the local Ollama model, through litelm, to generate responses."
4 (let ((story (load-story story-file)))
The function accepts two keyword parameters. story-file defaults to story.txt beside the source file, and model defaults to *ollama-model* but can be overridden to use any model you have pulled locally.
1 (unless story
2 (return-from play))
We load the story file. If it is missing (load-story returns nil), we exit early. The unless/return-from pattern is cleaner than nesting the entire game inside an if.
1 (let ((messages (list (list :system story))))
This is the critical data structure: the message history, a list of (role content) pairs in litelm’s message format. The system message comes first and sets up the game world; the :system, :user, and :assistant keywords are translated by litelm into the role values the API expects, so nothing in the game has to know any JSON.
1 (format t "~a~%~%" story)
2 (format t "Welcome to the Text Adventure!~%")
3 (format t "Describe what you want to do, or type 'quit' to exit.~%~%")
We print the system prompt so the player sees the world description, followed by a welcome message and instructions.
1 (loop
2 (format t "> ")
3 (force-output)
4 (let ((user-input (string-trim '(#\Space #\Tab #\Newline) (read-line))))
The game loop is an infinite loop form. We print a > prompt, call force-output to ensure it appears before the blocking read-line, then read and trim the player’s input.
1 (when (member user-input '("quit" "exit") :test #'string-equal)
2 (format t "Goodbye!~%")
3 (return))
Typing quit or exit ends the game. string-equal makes the test case-insensitive, so Quit and QUIT work too — the older member test against four literal spellings quietly missed those.
1 ;; An empty line simply re-prompts: the body of the turn is skipped
2 ;; and LOOP goes round again. (An earlier version tried to jump with
3 ;; (go :continue), but LOOP defines no such tag, so pressing Enter
4 ;; signalled "attempt to GO to nonexistent tag".)
5 (unless (string= user-input "")
An empty line is simply ignored: the unless skips the rest of the turn — no message is appended and the model is not called — and the loop prints the prompt again. The comment in the code is worth reading, because the obvious way to write this is wrong. An earlier draft used (go :continue), assuming that loop provides a :continue tag. It does not: loop does expand into a block and a tagbody, but the tags it generates are its own, so (go :continue) is an error — attempt to GO to nonexistent tag: :CONTINUE — and the game died the first time the player pressed Enter on an empty line.
1 (setf messages (append messages (list (list :user user-input))))
We append the player’s input as a user message. Note that we use append rather than a destructive operation: each turn creates a fresh list, which avoids mutation bugs. For a game that runs for dozens of turns the copying overhead is negligible.
1 (let ((response (chat messages :model model)))
2 (when response
3 (format t "~a~%" response)
4 (setf messages (append messages
5 (list (list :assistant response))))))))))))
We call chat with the full message history and the model name. chat hands the conversation to litelm:completion, which posts it to Ollama and returns the reply; we print it and append it to the history as an assistant message, so the model remembers what it said on future turns.
The Ollama Chat Function
For completeness, here is everything the game does to talk to Ollama:
1 (defvar *ollama-model* "qwen3.5:2b"
2 "Ollama model to play with. litelm needs a \"provider/model\" string, so
3 this may be either \"qwen3.5:2b\" or \"ollama/qwen3.5:2b\".")
4
5 (defvar *ollama-api-base* nil
6 "Optional override for the Ollama base URL passed to litelm. NIL uses
7 litelm's built-in default, http://localhost:11434/v1.")
8
9 (defparameter *story-file*
10 (merge-pathnames "story.txt"
11 (make-pathname :name nil :type nil
12 :defaults (or *load-truename*
13 *default-pathname-defaults*)))
14 "Default system-prompt file: story.txt next to this source file, so the game
15 runs no matter what the REPL's current directory is.")
16
17 (defun ensure-model-name (model)
18 "Prefix MODEL with \"ollama/\" unless it already names a provider."
19 (if (find #\/ model)
20 model
21 (concatenate 'string "ollama/" model)))
22
23 (defun chat (messages &key (model *ollama-model*))
24 "Send the multi-turn MESSAGES (a list of (role content) pairs) to the local
25 Ollama server through litelm and return the assistant's text."
26 (litelm:response-content
27 (litelm:completion (ensure-model-name model)
28 :messages messages
29 :api-base *ollama-api-base*)))
That is the entire transport layer. litelm addresses models as "provider/model" strings, so ensure-model-name prefixes a bare Ollama tag with ollama/ — which also means a fully qualified "ollama/qwen3.5:4b" passes through untouched. Everything else belongs to litelm: building the JSON payload, POSTing it to http://localhost:11434/v1/chat/completions, decoding the reply, and mapping an HTTP failure onto a Lisp condition. *ollama-api-base* is there if you need to point at a different host; nil means litelm’s default.
This is a large simplification over the earlier version of this file, which encoded the request with cl-json, patched the result with a string substitution (because cl-json writes nil as null where Ollama wants false), and shelled out to curl with uiop:launch-program. Routing through litelm removes all of it — and has the pleasant side effect that the same chat function serves the Fireworks variant, where only the model name changes.
The Fireworks.ai variant
text-adventure-game_fireworks.lisp is the same program pointed at a cloud provider:
1 (defvar *fireworks-model* "fireworks-ai/accounts/fireworks/models/deepseek-v4-flash"
2 "Fireworks model to play with, written as a litelm \"provider/model\" string.")
3
4 (defparameter *story-file*
5 (merge-pathnames "story.txt"
6 (make-pathname :name nil :type nil
7 :defaults (or *load-truename*
8 *default-pathname-defaults*)))
9 "Default system-prompt file: story.txt next to this source file, so the game
10 runs no matter what the REPL's current directory is.")
11
12 (defun chat (messages &key (model *fireworks-model*))
13 "Send the multi-turn MESSAGES (a list of (role content) pairs) to Fireworks
14 through litelm and return the assistant's text."
15 (litelm:response-content
16 (litelm:completion model :messages messages)))
The differences are only the model string — which already carries its fireworks-ai/ provider prefix, so ensure-model-name is not needed — and the FIREWORKS_API_KEY environment variable that litelm reads for that provider. Everything else, including play and the game loop, is identical, which is the real payoff of routing through a shared library.
Running the Game
Make sure Ollama is running and you have at least one chat model pulled:
1 ollama pull qwen3.5:2b
Then start your Lisp implementation and load the game:
1 CL-USER 1 > (load "text-adventure-game_ollama.lisp")
2 CL-USER 2 > (text-adventure:play)
To use a different model, pass the :model keyword: (text-adventure:play :model "qwen3.5:4b").
Example Session
Here is a representative playthrough:
1 CL-USER 1 > (load "text-adventure-game_ollama.lisp")
2 CL-USER 2 > (text-adventure:play)
3 You are a text adventure game master. Create an immersive, interactive story
4 for the player. Follow these rules:
5
6 1. Describe the current scene vividly but concisely - include what the player
7 sees, hears, and smells.
8 2. Present 2-4 clear options for what the player can do next at the end of
9 each response.
10 3. Track the player's inventory, health, and progress through the adventure.
11 4. Introduce surprising twists, interesting NPCs, and challenging obstacles.
12 5. Respond to the player's chosen action by advancing the story in a coherent
13 way.
14 6. Maintain internal consistency - remember what has happened before.
15
16 The adventure begins in the Valley of the Troll...
17
18 Welcome to the Text Adventure!
19 Describe what you want to do, or type 'quit' to exit.
20
21 > I walk up the winding path, crossing the stream. What do I see?
22
23 The air hangs thick and damp over your shoulders as a low wind sweeps through
24 the valley floor, carrying the scent of pine resin and wet stone from across
25 the stream bed just ahead. Your boots kick up small ribbons of mud upon the
26 soft moss, which crunches softly underfoot with every step you take toward
27 the winding path.
28
29 Ahead lies the narrow trail leading to where the Troll's mountain looms in
30 silhouette above a dark cave mouth or cliff face that blocks off direct view
31 into the lair itself - the entrance hidden by dense trees and ancient stone
32 formations. The mist clings to your boots, dampening any potential light you
33 might hold against the gloom of the tunnel.
34
35 **Inventory:** {None} | **Health:** 10/10 (Freshly restored)
36
37 **Your Options:**
38
39 A) Follow the path upward and investigate the cave entrance directly for
40 clues about how to reach or secure the Chalice, but beware of hidden traps.
41 B) Turn back toward the source of the valley mist; perhaps an elder troll
42 guard waits there who knows where you came from?
43 C) Scan the surrounding area with your senses before proceeding further up
44 towards higher ground for better visibility.
45
46 > I choose option C - I want to scan the area carefully before moving on.
47
48 You kneel down and run your fingers through the damp soil, closing your eyes
49 to focus on the subtle clues the valley might offer. The wind shifts...
50
51 > quit
52 Goodbye!
What Makes This Work
The key insight is that the LLM is doing all the creative work (e.g., world-building, character dialogue, plot twists) while our Common Lisp code handles only the mechanical concerns: reading input, maintaining state, and routing messages. This separation of concerns is what makes the program so short.
The message history is the game state. There is no separate inventory tracker, health counter, or quest log in our code. The LLM tracks these in the conversation itself, for example notice how the assistant response includes “Inventory: {None} | Health: 10/10.” This works because each API call includes the full conversation history, so the model can reference anything it (or the player) said previously.
The system prompt does the heavy lifting. By telling the model how to be a game master — describing scenes, presenting options, tracking state, maintaining consistency — we get structured, game-like responses without any parsing or post-processing. A poorly written system prompt would produce rambling narration without clear choices; a well-written one feels like a real game.
Ollama makes it free and private. Running locally means no API costs, no rate limits, and no data leaving your machine. The trade-off is that smaller models (2B–7B parameters) produce simpler stories than cloud-hosted giants like GPT-4 or Claude. For a fun interactive experience, the smaller models work surprisingly well.
Customizing the Adventure
To create your own adventure, edit story.txt and change:
- Setting: Replace “Valley of the Troll” with your world such as a derelict spaceship, a 1920s speakeasy, or a wizard’s tower.
- Goal: What is the player trying to accomplish? Find an artifact, solve a murder, escape a dungeon?
- Rules: Add constraints like “The player cannot use violence” or “All puzzles require rhyming solutions.”
- Tone: Instruct the model to be humorous, terrifying, mysterious, or whimsical.
You can also experiment with different Ollama models. A larger model such as qwen3.5:4b produces richer prose and follows complex instructions better, while the default qwen3.5:2b is faster but may occasionally forget details from earlier in the conversation.
Wrap Up
This chapter demonstrated how a small amount of Common Lisp code can harness an LLM to create an open-ended interactive experience. The pattern of using a system prompt, accumulating message history, calling chat API, and appending response generalizes beyond games to any conversational AI application: tutoring systems, interactive fiction, role-playing scenarios, and creative writing tools.
The complete source code lives in the text-adventure-game directory of the book’s repository. Two backend variants are provided:
text-adventure-game_ollama.lisp: uses Ollama for local, private, cost-free inference. Requires Ollama running locally with at least one chat model pulled.text-adventure-game_fireworks.lisp: uses the Fireworks.ai cloud API for much faster responses. Requires aFIREWORKS_API_KEYenvironment variable. Fireworks.ai hosts optimized versions of models like DeepSeek and Llama on dedicated GPU infrastructure, delivering sub-second latency that makes the game feel significantly more responsive.
Both files are under 100 lines of Lisp — most of it the game loop — because the depth comes from the LLM: it improvises within the constraints set by your system prompt.
Optional Practice Problems
Custom Adventure: Create a new
story.txtfile with a completely different setting (space station, underwater city, haunted mansion). Run the game with your custom prompt and play through at least five turns. Observe how the model adapts to the new world. Compare the quality of responses with at least two different Ollama models.Save and Load: Extend the game with a
save-gamefunction that writes the message history to a file (usinglitelm:json-encode) and aload-gamefunction that restores it. Add commandssaveandloadto the game loop. This reinforces working with JSON serialization and file I/O in Common Lisp.Model Switcher: Add a command
model <name>that changes the model mid-game without losing the conversation history. Implement this by modifying theplayfunction to accept a dynamic model parameter rather than a one-time keyword argument. Consider: does switching models mid-story produce coherent results?Token Counter: Add a diagnostic command
tokensthat estimates how many tokens the current message history contains. Ollama reportsprompt_eval_countandeval_counton every response (visible inlitelm:response-raw), so you can read the real numbers instead of estimating. If you would rather not depend on them, fall back to a heuristic: count characters and divide by 4. Print the estimate alongside the number of turns played.Multi-NPC Conversations: Modify the system prompt to introduce two distinct NPCs with different personalities (e.g., a sarcastic goblin and a nervous elf). During play, try addressing each NPC by name and observe whether the model maintains distinct voices for each. Write a brief analysis of how well the model handles this and what prompt engineering techniques improved the results.
Inventory System: Enhance the
playfunction to parse inventory changes from the LLM’s responses. Look for patterns like “Inventory: {Sword, Shield}” in the response text, extract the items, and maintain a separate inventory list in Lisp. Display this list alongside the prompt. This exercise combines string parsing with state management.