An Interactive Text Adventure Game with Ollama
The previous Ollama chapter built a one-shot client: send a prompt, get a response, done. Real applications of language models are rarely one-shot. A chatbot, a coding assistant, a game master, or a tutoring agent all need to hold a conversation across many turns, keeping context so each reply builds on what came before.
In this chapter we build an interactive text adventure game powered by a local Ollama model. The game maintains a running conversation history, sends it to the Ollama chat API on every turn, and uses the model as a dynamic game master that responds to player actions in real time.
All code is in source-code/text-adventure-game.
From Generate to Chat
The Ollama client in the previous chapter used the /api/generate endpoint. That endpoint takes a single prompt string and returns a single completion. It has no memory. If you call it twice, the second call knows nothing about the first.
The /api/chat endpoint solves this. Instead of a single prompt string, it takes a list of messages, each tagged with a role. Three roles carry the conversation:
- system: an opening instruction that sets the model’s persona, tone, and rules for the whole conversation. In our game this is the story prompt that tells the model to act as a game master.
- user: a turn written by the human player.
- assistant: a turn previously generated by the model.
The API is stateless. The server remembers nothing between calls. To hold a multi-turn conversation, the client must store the full message history and resend it on every request, appending the latest user input and the latest assistant reply as they arrive. This is the same pattern the OpenAI chapter described, applied here to a local Ollama instance.
The Story Prompt as Data
Before we look at the code, here is the data that drives the game. The file story.txt contains the system prompt. This is the text that tells the model what world to build, what tone to use, and how to format its responses:
1 You are an interactive AI-driven text adventure game master. Create immersive, engaging storylines that respond dynamically to player input and actions.
2
3 ## Core Directives
4 1. **Maintain continuity** - Remember all significant events, characters, items, and decisions from the start of the adventure unless explicitly changed or lost through in-game mechanics.
5 2. **Provide 3-5 meaningful choices** at natural break points, presenting clear options that reflect potential paths forward. Never repeat previous choices verbatim; each option should propose a distinct course of action.
6 3. **Track player state explicitly** - Keep the following persistent variables visible: current health, inventory (list all items), and any active quest objectives or conditions. Update these systematically after each response using bracket notation.
7 4. **Describe scenes vividly** - Use multi-sensory details (sight, sound, smell, touch) to create atmosphere. Maintain a consistent tone throughout based on the genre hints above.
8
9 ## Genre Instructions: Dark High Fantasy Horror Setting
10 - The world is called **Briarwyrm Hollow**, a cursed valley shrouded in perpetual mist where ancient evils stir. The player is a lone traveler seeking answers about their missing companion who vanished here three days ago.
11 - Emphasize oppressive atmosphere: the mist clings to everything, creating disorientation and muffled sounds; shadows writhe independently of light sources; unease settles like a weight in the pit of the player's stomach.
12 - Introduce NPCs sparingly but make them memorable when they appear - each should have a distinct voice, motive, and potential for betrayal or aid. Give names to even minor characters.
13 - Combat should feel desperate rather than triumphant. Describe injuries vividly, emphasize scarcity of resources (torches burn fast, food is meager), and make the player's wit and caution as important as any weapon.
14
15 ## Response Format
16 Present each response in exactly this format:
[Scene description - 3-5 paragraphs]
State Updates:
- Health: [current]/100
- Inventory: {[list all items, or None]}
- Quests/Conditions: {[active objectives, or None]}
Your Choices: A) [Action option with brief consequence preview] B) [Different action path] C) [Alternative approach]
1 ## Starting Conditions
2 Begin the adventure immediately as dawn breaks over Briarwyrm Hollow. The player stands at the valley's edge, mist curling around their boots, a single backpack holding minimal supplies. Their companion's last known message mentioned "something is awake in the catacombs below." Let the first scene establish mood and present initial choices that lead into exploration of the valley's mysteries.
3
4 The adventure begins now. Describe what lies before the player...
This prompt does several things at once. It sets the genre (dark fantasy horror), gives the model rules for continuity and state tracking, specifies a rigid response format so the output stays structured across turns, and provides a starting scene. The model reads this once as the system message and refers back to it on every turn because the full history is resent each time.
The Data Model: Roles and Messages
Now the code. The first thing we need is a data model for chat messages. The file Main.scala defines an enum for the three roles and a case class for a single message:
1 //> using scala 3.6.4
2 //> using dep com.lihaoyi::requests:0.9.3
3 //> using dep com.lihaoyi::upickle:4.4.3
4
5 package textadventure
6
7 import upickle.default.*
8 import ujson.*
9
10 /** Represents a chat message in the conversation history */
11 enum Role(val value: String):
12 case System extends Role("system")
13 case User extends Role("user")
14 case Assistant extends Role("assistant")
15
16 case class ChatMessage(role: Role, content: String)
The Role enum carries its JSON string value as a parameter. Role.System maps to "system", Role.User to "user", and so on. This makes serialization straightforward: we can read role.value when building JSON, and we can search Role.values to find the matching case when parsing JSON back.
The ChatMessage case class pairs a role with its text content. A conversation is simply a List[ChatMessage], grown one message at a time as the game progresses.
JSON Serialization with upickle
The Ollama chat API expects each message as a JSON object with role and content fields. We use the upickle library to convert between ChatMessage and JSON. A given ReadWriter tells upickle how to perform that conversion in both directions:
1 object ChatMessage:
2 given ReadWriter[ChatMessage] = readwriter[ujson.Value].bimap[ChatMessage](
3 msg => Obj("role" -> msg.role.value, "content" -> msg.content),
4 json =>
5 val obj = json.obj
6 val roleStr = obj("role").str
7 val content = obj("content").str
8 val role = Role.values
9 .find(_.value == roleStr)
10 .getOrElse(throw RuntimeException(s"Unknown role: $roleStr"))
11 ChatMessage(role, content)
12 )
The bimap function takes two functions. The first serializes a ChatMessage into a ujson.Obj with the right field names. The second parses a ujson.Value back into a ChatMessage, looking up the Role by its string value. The find(_.value == roleStr) call searches the enum cases for one whose value matches the JSON string. We need this lookup because Role.valueOf("system") would look for a case named System, not one whose value is "system", and the JSON uses lowercase strings.
The Ollama Chat Client
With the data model in place, we build the client that talks to Ollama. The OllamaChatClient object sends a list of messages to the /api/chat endpoint and returns the model’s response as an Either[String, String]. Using Either instead of throwing exceptions lets the caller handle errors in the game loop without a try/catch block:
1 object OllamaChatClient:
2 private val DEFAULT_MODEL = "qwen3.5:4b"
3 private val DEFAULT_BASE_URL = "http://localhost:11434"
4
5 def getChat(
6 messages: List[ChatMessage],
7 model: String = DEFAULT_MODEL,
8 baseUrl: String = DEFAULT_BASE_URL
9 ): Either[String, String] =
10
11 val url = s"$baseUrl/api/chat"
12
13 val messagesJson = Arr(messages.map(msg => writeJs(msg))*)
14 val payload = Obj(
15 "model" -> model,
16 "messages" -> messagesJson,
17 "stream" -> false
18 ).render()
19
20 try
21 val response = requests.post(
22 url = url,
23 headers = Map("Content-Type" -> "application/json"),
24 data = payload,
25 readTimeout = 300000
26 )
27
28 if response.statusCode == 200 then
29 val json = ujson.read(response.text())
30 val content = json("message")("content").str
31 Right(content)
32 else
33 Left(
34 s"Ollama API request failed (HTTP ${response.statusCode}): ${response.text()}"
35 )
36 catch
37 case e: Exception =>
38 Left(
39 s"Error connecting to Ollama: ${e.getMessage}. Please ensure Ollama is running."
40 )
Several details deserve attention.
The writeJs(msg) call uses the given ReadWriter we defined to serialize each ChatMessage into a ujson.Value. The * operator spreads the resulting sequence into the Arr constructor as varargs, producing a JSON array of message objects.
The stream field is set to false, just as in the generate client. With streaming enabled, the server sends each token as it is produced. We disable streaming so the server buffers the full response and returns it as one JSON object, which is simpler to parse. The response shape for the chat endpoint differs from the generate endpoint: the text lives in json("message")("content") rather than json("response").
The read timeout is 5 minutes (300,000 milliseconds). Local inference on consumer hardware can be slow, and the first request after the model loads into memory may take even longer. A generous timeout prevents the client from giving up on a valid but slow response.
The Either return type means success is Right(content) and failure is Left(errorMessage). The two failure cases are an HTTP error (status code not 200) and a connection error (Ollama not running, network issue). Both produce a descriptive Left that the game loop can print to the user.
The Game Loop
The TextAdventureGame object ties everything together. It loads the story prompt, builds the conversation history, and runs the read-eval-print loop that drives the game:
1 object TextAdventureGame:
2
3 val STORY_PROMPT_FILE = "story.txt"
4 val DEFAULT_MODEL = sys.env.getOrElse("TEXT_ADV_OLLAMA_MODEL", "qwen3.5:4b")
5
6 def loadStoryPrompt(): Either[String, String] =
7 try
8 val content = scala.io.Source.fromFile(STORY_PROMPT_FILE).mkString
9 Right(content)
10 catch
11 case _: java.io.FileNotFoundException =>
12 Left(
13 s"Could not find $STORY_PROMPT_FILE. Please ensure it exists in the project directory."
14 )
15 case e: Exception =>
16 Left(s"Error reading story prompt: ${e.getMessage}")
The loadStoryPrompt function reads story.txt from disk and returns its contents as an Either. The model name can be overridden with the TEXT_ADV_OLLAMA_MODEL environment variable, which lets you switch models without editing the source.
The main game loop is the play function:
1 @main def play(): Unit =
2 println("=" * 60)
3 println("TEXT ADVENTURE GAME")
4 println("=" * 60)
5
6 val prompt = loadStoryPrompt() match
7 case Left(err) =>
8 println(s"Error: $err")
9 return
10 case Right(p) => p
11
12 val model = DEFAULT_MODEL
13 println(s"\nUsing Ollama model: $model")
14 println("Ensure Ollama is running (ollama serve)")
15
16 var history: List[ChatMessage] =
17 List(ChatMessage(Role.System, prompt))
18
19 println("\n" + "-" * 60)
20 println("Welcome to the Text Adventure!")
21 println("Type your actions at the > prompt.")
22 println("Type 'quit', 'exit', or 'q' to end the game.")
23 println("-" * 60)
24
25 print("\n> ")
26
27 var running = true
28 while running do
29 val input = scala.io.StdIn.readLine()
30
31 if input == null || input.trim.isEmpty then
32 print("> ")
33 else
34 val trimmed = input.trim
35
36 if trimmed.toLowerCase == "quit" || trimmed.toLowerCase == "exit" || trimmed.toLowerCase == "q"
37 then
38 println("\nThanks for playing!")
39 running = false
40 else
41 history = history :+ ChatMessage(Role.User, trimmed)
42
43 print("\nProcessing...\n")
44
45 OllamaChatClient.getChat(history, model) match
46 case Left(error) =>
47 println(s"Error: $error")
48 history = history.dropRight(1)
49
50 case Right(response) =>
51 val cleanResponse = response.trim
52 println("\n" + cleanResponse)
53 history = history :+ ChatMessage(Role.Assistant, cleanResponse)
54
55 print("> ")
The loop follows a simple cycle:
- Read a line of input from the player.
- Append it to the history as a
Usermessage. - Send the full history to Ollama.
- On success, print the response and append it to the history as an
Assistantmessage. - On failure, print the error and remove the user message from history so the player can retry.
Step 5 is a small but important detail. When the API call fails, history.dropRight(1) removes the user message that was just appended. Without this, the next request would resend a user message that got no reply, which could confuse the model.
The history variable is a var holding an immutable List. Each append creates a new list with :+, leaving the old list untouched. This is idiomatic Scala: the list itself is immutable, but the reference to it is mutable so the loop can update it. The full history grows by two messages per turn (one user, one assistant) and is sent in its entirety on every request. This is what gives the model its memory of the conversation.
A Connection Test Entry Point
The file also defines a second @main function for testing whether Ollama is reachable and the model is available, without starting the full game:
1 @main def testOllama(model: String = "qwen3.5:4b"): Unit =
2 println(s"Testing Ollama connection with model '$model'...")
3
4 val messages = List(
5 ChatMessage(Role.System, "You are a helpful assistant."),
6 ChatMessage(Role.User, "Hello! Please respond with a brief greeting.")
7 )
8
9 OllamaChatClient.getChat(messages, model) match
10 case Right(response) =>
11 println(s"Success!\nResponse:\n$response")
12 case Left(error) =>
13 println(s"Failed: $error")
This sends a minimal two-message conversation and prints the result. It is a quick way to verify that Ollama is running, the model is pulled, and the API client works, before you commit to a longer game session.
Building and Running
The project includes a Makefile that wraps the scala-cli commands:
1 .SHELL := /bin/bash
2 SCALA_CLI := scala-cli
3 SCALA_FLAGS := --server=false
4 MODEL ?= mistral
5
6 .PHONY: all compile run test fmt clean test-ollama
7
8 all: compile
9
10 compile:
11 $(SCALA_CLI) compile . $(SCALA_FLAGS)
12
13 run:
14 $(SCALA_CLI) run . $(SCALA_FLAGS) --main-class textadventure.play
15
16 test:
17 $(SCALA_CLI) test . $(SCALA_FLAGS)
18
19 fmt:
20 $(SCALA_CLI) fmt . $(SCALA_FLAGS)
21
22 test-ollama:
23 $(SCALA_CLI) run . $(SCALA_FLAGS) --main-class textadventure.testOllama -- $(MODEL)
24
25 clean:
26 rm -rf .scala-build .bsp .poolside
Before running, make sure Ollama is installed and the model is pulled. You can use any model Ollama supports. The default in the code is qwen3.5:4b, but you can override it with the TEXT_ADV_OLLAMA_MODEL environment variable or pass a different model to make test-ollama:
1 # Start the Ollama server
2 ollama serve
3
4 # Pull the model
5 ollama pull qwen3.5:4b
6
7 # Test the connection
8 make test-ollama MODEL=qwen3.5:4b
9
10 # Play the game
11 make run
The connection test prints:
1 Testing Ollama connection with model 'qwen3.5:4b'...
2 Success!
3 Response:
4 Hello! I'm here and ready to help. What can I do for you today?
The game itself starts like this:
1 ============================================================
2 TEXT ADVENTURE GAME
3 ============================================================
4
5 Using Ollama model: qwen3.5:4b
6 Ensure Ollama is running (ollama serve)
7
8 ------------------------------------------------------------
9 Welcome to the Text Adventure!
10 Type your actions at the > prompt.
11 Type 'quit', 'exit', or 'q' to end the game.
12 ------------------------------------------------------------
13
14 > I look around and listen for any sounds in the mist.
15
16 Processing...
17
18 The pale light of dawn seeps through the perpetual mist that blankets Briarwyrm Hollow. You stand at the valley's edge, where gnarled trees twist upward like grasping hands. The air is cold and damp, carrying the faint scent of wet earth and something older, something that should not be disturbed.
19
20 A muffled sound reaches your ears from deeper in the valley. It might be wind through the catacombs your companion mentioned, or it might be something breathing. You cannot tell. Your backpack feels heavier than it should, its straps cutting into your shoulders.
21
22 The mist parts briefly to reveal a narrow path winding down into the hollow. To your left, a crumbling stone marker bears faded runes. To your right, the remains of a campfire, long cold, with a torn scrap of cloth caught on a nearby branch.
23
24 **State Updates:**
25 - Health: 100/100
26 - Inventory: {Backpack, 2 torches, 1 day's rations, waterskin, rusty dagger}
27 - Quests/Conditions: {Find missing companion, Investigate the catacombs}
28
29 **Your Choices:**
30 A) Follow the path down into the valley toward the sound
31 B) Examine the stone marker and try to read the runes
32 C) Inspect the cold campfire and the torn cloth
33 D) Light a torch and check the immediate area before moving
34
35 >
Interpreting the Results
The output shows several things working at once.
The model follows the response format specified in story.txt. It produces a scene description, a state update block with health, inventory, and quest conditions, and a set of labeled choices. This structure is not accidental. The system prompt told the model to use exactly this format, and because we resend the full history on every turn, the model sees that instruction each time and stays consistent.
The state tracking is the key feature that makes this more than a random story generator. The model reports health at 100/100, lists the starting inventory, and names the active quests. On subsequent turns, if the player takes damage or picks up an item, the model updates these values because the conversation history contains every prior state update. The model can see what it wrote last time and adjust from there.
The choices give the player agency. Each one proposes a distinct action with a brief hint of its consequence. The player types a response, which becomes a new User message, and the cycle repeats. The game maintains continuity because the history list grows with every turn and is always sent in full.
One practical concern with this design is that the history grows without bound. Each turn adds two messages to the list, and every request resends all of them. Over a long session the request payload gets large, which slows inference and consumes the model’s context window. A production version would need a strategy for trimming or summarizing old history. For a short game session this is not a problem, but it is worth understanding the trade-off.
Wrap Up
This chapter took the Ollama client from a one-shot prompt to a full interactive application. The key ideas were the chat API endpoint, which accepts a list of role-tagged messages instead of a single prompt, and the conversation history, which the client maintains and resends on every turn to give the model its memory.
The data model is small: an enum for roles, a case class for messages, a given ReadWriter for JSON serialization. The game loop is small: read input, append to history, call the API, append the response. The real complexity lives in the story prompt, which is plain text, not code. That is the shift this chapter illustrates. The behavior of the application, its genre, its rules, its format, its state tracking, all come from a text file that instructs the model. The Scala code provides the plumbing: it moves messages between the player, the history, and the API. The model does the rest.
This pattern generalizes beyond games. Any application that needs a language model to maintain state across turns, follow rules, and respond to user input uses the same architecture: a system prompt that sets the rules, a growing message history, and a client that resends it all on every call.
Optional Practice Problems
Add a command to save and load the game. Write the current
historylist to a JSON file when the player typessave, and read it back into thehistoryvariable when the player typesload <filename>. Use theChatMessageReadWriteryou already have to serialize and deserialize the list withupickle.default.writeandread.Trim old history to manage context length. Add a function that keeps the system message and the last N user/assistant pairs but drops older messages. Call it when the history exceeds a configurable threshold. Compare game behavior before and after trimming. Does the model lose track of earlier events?
Add a second entry point that plays the game non-interactively. Write an
@mainfunction that takes a sequence of player actions as command-line arguments, feeds them to the game one at a time, and prints each response. This lets you script a playthrough for testing without typing each action by hand.Support multiple story files. Change
loadStoryPromptto accept a filename argument, and let the player choose a story at startup by listing the.txtfiles in the project directory. Write a second story prompt with a different genre and setting to test it.Add a token usage estimate. After each API response, estimate how many tokens the request consumed by counting words in the history (a rough proxy is one token per 0.75 words). Print the running total so the player can see how the context grows over a session.