A Text Adventure Game With a Fireworks.ai LLM
This chapter ports the text adventure game from my book “Artificial Intelligence Using Python” to Swift. The Python version calls Fireworks.ai through litellm. Swift needs no wrapper library: Fireworks.ai serves an OpenAI compatible chat completions endpoint, so URLSession and Codable are enough. The program has no external dependencies.
Dear reader, note that you can swap in any other OpenAI compatible provider endpoint and model.
The example code for this chapter is in the directory source-code/text-adventure-game.
The LLM plays the Game Master. A text file named story.txt holds the Game Master persona and the setting, and the program sends it as the system prompt. Each turn, the player types an action, the program resends the whole conversation, and the model writes the next scene. The game logic only manages a list of chat messages, which makes it a good small project for learning the chat completions API.
Running the Game
Set your Fireworks API key, then run:
1 export FIREWORKS_API_KEY="your-api-key"
2 swift run game
The program prints a banner and lists the commands:
1 $ swift run game
2 Building for debugging...
3 Build complete! (0.13 sec)
4 ============================================================
5 TEXT ADVENTURE GAME
6 Powered by Fireworks.ai - deepseek-v4p1-flash
7 ============================================================
8
9 Commands: /help /restart /quit
10
11 The valley mouth yawns before you, a jagged split in the sheer cliffs where cold air carries the scent of damp stone and old magic. Your map, brittle and faded, marks this as the only entrance—and a single word in the margin: *"Beware the Silent Watcher."* A worn path winds downward through twisted pines, while to your left, a crumbling stone archway glows faintly with runes.
12
13 **Inventory:** Map, Dagger, 3 Gold Coins
14
15 **Choices:**
16 1. Follow the worn path into the pines.
17 2. Examine the runed archway.
18 3. Study the strange markings on the cliff wall.
19 4. Call out a greeting to the valley.
20
21 What do you do?
22
23 walk towards the twisted pines
24
25 You descend the worn path, the twisted pines closing overhead like grasping fingers. After a hundred paces, the trail forks around a massive, split-trunk tree—and hanging from its lowest branch is a fresh rabbit, skinned and neatly tied with vine. Someone, or something, has been here recently. A soft humming drifts from beyond the tree, and a thin curl of smoke rises in the distance.
26
27 **Inventory:** Map, Dagger, 3 Gold Coins
28
29 **Choices:**
30 1. Take the hanging rabbit.
31 2. Follow the humming and smoke.
32 3. Climb the split-trunk tree for a better view.
33 4. Call out to whoever left the rabbit.
34
35 What do you do?
Here is the opening of a real session. Note that in this example run we did not use one of the four suggested actions, although we could have. The model wrote this scene from story.txt file contents. Feel free to modify the file story.txt.
The are also non-game commands:
| Command | Action |
|---|---|
/help |
Show available commands |
/restart |
Start a new adventure |
/quit |
Exit the game |
The Story File
The contents of story.txt become the system prompt, so editing the file changes the world, the rules, and the tone of the game. The same code makes a different game when the prompt changes:
1 You are the Game Master of a text adventure game. Your role is to:
2
3 1. Describe the current scene vividly but concisely (2-4 sentences).
4 2. Present 3-4 clear choices the player can take, labeled as options.
5 3. Respond to the player's choice by advancing the story in an interesting direction.
6 4. Keep the story coherent and remember what has happened.
7 5. Occasionally introduce surprises, puzzles, or challenges.
8 6. End with "What do you do?" after presenting options.
9
10 The setting: You are in a mysterious ancient valley rumored to hold a powerful artifact. Strange creatures and forgotten magic linger here. The player is an adventurer seeking the artifact.
11
12 Start the game by describing the entry to the valley and offering initial choices. Keep track of the player's inventory (they start with a map, a dagger, and 3 gold coins). Make choices matter - some lead to treasure, others to peril. Include at least one NPC the player can encounter.
The Swift code keeps no inventory, health, or save data. The model tracks all of that in the conversation history, which works well for a short adventure. A longer game needs a way to trim or summarize old turns before the history outgrows the model’s context window.
The Package Manifest
The package has two targets: a library named TextAdventureGame that holds the model, the client, and the game logic, and an executable target named Game that holds the interactive loop in a file named main.swift. Swift requires top level executable code to live in a file named main.swift, and a test target cannot import an executable target’s code as easily as a library’s. The split lets the tests use @testable import TextAdventureGame.
Package.swift:
1 // swift-tools-version: 6.1
2 import PackageDescription
3
4 let package = Package(
5 name: "TextAdventureGame",
6 platforms: [
7 .macOS(.v13)
8 ],
9 products: [
10 .executable(name: "game", targets: ["Game"]),
11 .library(
12 name: "TextAdventureGame",
13 targets: ["TextAdventureGame"]
14 ),
15 ],
16 targets: [
17 .target(
18 name: "TextAdventureGame"
19 ),
20 .executableTarget(
21 name: "Game",
22 dependencies: ["TextAdventureGame"]
23 ),
24 .testTarget(
25 name: "TextAdventureGameTests",
26 dependencies: ["TextAdventureGame"]
27 ),
28 ],
29 // The game is a single-threaded terminal loop; plain
30 // statics are fine.
31 swiftLanguageModes: [.v5]
32 )
Chat Messages and the Client Protocol
Each entry in the conversation is a role and a string. Codable turns it into the JSON the API expects with no extra work, because the case names system, user, and assistant match the wire format:
1 public struct ChatMessage: Codable, Equatable, Sendable {
2 public enum Role: String, Codable, Sendable {
3 case system, user, assistant
4 }
5 public var role: Role
6 public var content: String
7
8 public init(role: Role, content: String) {
9 self.role = role
10 self.content = content
11 }
12 }
The game depends on a protocol, not on the concrete client. This lets you test the game logic without an API key:
1 /// The chat-completion operation the game depends on.
2 /// FireworksClient is the real implementation; tests
3 /// substitute a mock.
4 public protocol ChatCompleting: Sendable {
5 func chat(messages: [ChatMessage]) async throws -> String
6 }
The Fireworks Client
A chat completions request is a JSON POST with a bearer token. The response puts the model’s reply in the content field of the first choice. FireworksClient is a struct that holds its configuration, in the same style as the OpenAI struct in the chapter on the OpenAI APIs:
1 public struct FireworksClient: ChatCompleting {
2 public struct APIError: Error, CustomStringConvertible {
3 let message: String
4 public var description: String { message }
5 }
6
7 private struct ChatRequest: Encodable {
8 let model: String
9 let messages: [ChatMessage]
10 let max_tokens: Int
11 let temperature: Double
12 }
13
14 private struct Choice: Decodable {
15 struct Message: Decodable { let content: String? }
16 let message: Message
17 }
18
19 private struct ChatResponse: Decodable {
20 struct APIErrorBody: Decodable { let message: String? }
21 let error: APIErrorBody?
22 let choices: [Choice]?
23 }
24
25 public let apiKey: String
26 public let session: URLSession
27 public let endpoint: String
28 public let model: String
29
30 public init() throws {
31 guard let key = ProcessInfo.processInfo
32 .environment[Config.apiKeyEnv],
33 !key.isEmpty else {
34 throw APIError(
35 message: "\(Config.apiKeyEnv) environment "
36 + "variable not set")
37 }
38 self.apiKey = key
39 self.session = .shared
40 self.endpoint = Config.endpoint
41 self.model = Config.model
42 }
The snake case property name max_tokens is intentional: Codable maps struct properties to JSON keys by name, so matching the API removes the need for a CodingKeys enum. The endpoint, model, and key name live in one enum:
1 enum Config {
2 static let model =
3 "accounts/fireworks/models/deepseek-v4p1-flash"
4 static let endpoint =
5 "https://api.fireworks.ai/inference/v1/chat/completions"
6 static let apiKeyEnv = "FIREWORKS_API_KEY"
7 }
The call uses the async URLSession method data(for:). It checks the HTTP status first and reports the raw response body, which gives a clearer message than a JSON decoding error:
1 /// Send the conversation history to the model and
2 /// return its reply.
3 public func chat(
4 messages: [ChatMessage]
5 ) async throws -> String {
6 guard let url = URL(string: endpoint) else {
7 throw APIError(message: "invalid endpoint URL")
8 }
9 var request = URLRequest(url: url)
10 request.httpMethod = "POST"
11 request.timeoutInterval = 120
12 request.setValue("application/json",
13 forHTTPHeaderField: "Content-Type")
14 request.setValue("Bearer \(apiKey)",
15 forHTTPHeaderField: "Authorization")
16
17 let body = ChatRequest(model: model,
18 messages: messages,
19 max_tokens: 1024,
20 temperature: 0.8)
21 request.httpBody = try JSONEncoder().encode(body)
22
23 let (data, response) =
24 try await session.data(for: request)
25 if let http = response as? HTTPURLResponse,
26 http.statusCode != 200 {
27 let text = String(decoding: data, as: UTF8.self)
28 throw APIError(message: "HTTP \(http.statusCode): "
29 + "\(text.prefix(300))")
30 }
31 let decoded = try JSONDecoder()
32 .decode(ChatResponse.self, from: data)
33 if let err = decoded.error?.message {
34 throw APIError(message: "API error: \(err)")
35 }
36 guard let content = decoded.choices?.first?
37 .message.content, !content.isEmpty else {
38 throw APIError(message: "Model returned empty "
39 + "response")
40 }
41 return content
42 }
43 }
A temperature of 0.8 gives the Game Master variety. A low temperature makes the prose flat and repetitive. The max_tokens limit of 1024 caps the length of one scene.
The client also has a second initializer that takes an API key, session, endpoint, and model. Use it to point the same code at a local vllm or LM Studio server, or at DeepSeek, since all of them serve the same API.
The Game Class
Game owns the conversation history. Three methods cover everything the loop needs: load the story, start or restart an adventure, and take one turn. Note the rollback in takeTurn: when the call fails, the method removes the user message so a retry does not stack up unanswered turns in the history.
1 public final class Game {
2 private let client: any ChatCompleting
3 private let storyText: String
4 public private(set) var messages: [ChatMessage]
5
6 /// Load the game master instructions from story.txt:
7 /// the current directory first, then next to the
8 /// executable (Xcode "Run" launches from DerivedData).
9 public static func loadStory() -> String? {
10 let fm = FileManager.default
11 let candidates = [
12 fm.currentDirectoryPath + "/story.txt",
13 Bundle.main.executablePath.map {
14 (($0 as NSString)
15 .deletingLastPathComponent)
16 + "/story.txt"
17 } ?? "",
18 ]
19 for path in candidates
20 where fm.fileExists(atPath: path) {
21 return try? String(contentsOfFile: path,
22 encoding: .utf8)
23 }
24 return nil
25 }
26
27 public init(client: any ChatCompleting,
28 storyText: String) {
29 self.client = client
30 self.storyText = storyText
31 self.messages =
32 [ChatMessage(role: .system, content: storyText)]
33 }
34
35 /// Get the opening scene from the Game Master.
36 public func startAdventure() async throws -> String {
37 messages =
38 [ChatMessage(role: .system, content: storyText)]
39 messages.append(ChatMessage(
40 role: .user, content: "Start the adventure."))
41 let reply = try await client.chat(messages: messages)
42 messages.append(
43 ChatMessage(role: .assistant, content: reply))
44 return reply
45 }
46
47 /// Send one player action and return the reply.
48 public func takeTurn(
49 _ userInput: String
50 ) async throws -> String {
51 messages.append(
52 ChatMessage(role: .user, content: userInput))
53 do {
54 let reply = try await client.chat(
55 messages: messages)
56 messages.append(
57 ChatMessage(role: .assistant,
58 content: reply))
59 return reply
60 } catch {
61 messages.removeLast()
62 throw error
63 }
64 }
65 }
startAdventure also handles restarts, because it rebuilds the history from the system prompt. Dropping the old conversation gives a fresh game and resets the token cost of each turn.
The Interactive Loop
The executable target is a while true loop that reads a line, handles commands that start with a slash, and otherwise calls takeTurn. The whole file:
1 import Foundation
2 import TextAdventureGame
3
4 print(String(repeating: "=", count: 60))
5 print(" TEXT ADVENTURE GAME")
6 print(" Powered by Fireworks.ai - deepseek-v4p1-flash")
7 print(String(repeating: "=", count: 60))
8 print()
9 print("Commands: /help /restart /quit")
10 print()
11
12 guard let storyText = Game.loadStory() else {
13 print("Error: story.txt not found. Please create "
14 + "story.txt with your adventure setting.")
15 exit(1)
16 }
17
18 let client: FireworksClient
19 do {
20 client = try FireworksClient()
21 } catch {
22 print("Error: \(error)")
23 exit(1)
24 }
25
26 let game = Game(client: client, storyText: storyText)
27
28 // Get the opening scene.
29 let opening: String
30 do {
31 opening = try await game.startAdventure()
32 } catch {
33 print("Error connecting to Fireworks.ai: \(error)")
34 exit(1)
35 }
36 print(opening)
37
38 // Game loop.
39 gameLoop: while true {
40 print()
41 guard let raw = readLine() else {
42 print("\nThanks for playing!")
43 break
44 }
45 let userInput = raw.trimmingCharacters(in: .whitespaces)
46 if userInput.isEmpty { continue }
47
48 if userInput.hasPrefix("/") {
49 let cmd = userInput.lowercased()
50 switch cmd {
51 case "/quit", "/exit", "/q":
52 print("Thanks for playing!")
53 break gameLoop
54 case "/restart":
55 do {
56 let reply = try await game.startAdventure()
57 print("\n--- Restarted ---\n")
58 print(reply)
59 } catch {
60 print("Error: \(error)")
61 break gameLoop
62 }
63 case "/help":
64 print("Commands: /help /restart /quit")
65 print("Type your action or choice to advance "
66 + "the story.")
67 default:
68 print("Unknown command: \(userInput)")
69 }
70 continue
71 }
72
73 do {
74 let reply = try await game.takeTurn(userInput)
75 print()
76 print(reply)
77 } catch {
78 print("Error: \(error)")
79 print("Try again or type /quit to exit.")
80 }
81 }
Three details matter here. Inside a switch, a plain break exits only the switch, so the labeled break gameLoop exits the loop. readLine() returns nil at end of input, so you can pipe a file of commands into the game. A failed turn prints the error and keeps the game running: a rate limit should end the turn, not the program.
Testing the Game Without an API Key
The tests use the Swift Testing framework and a mock that implements ChatCompleting. Because Game talks to the protocol, no test needs a network or a key, so swift test works on any machine:
1 import Foundation
2 @testable import TextAdventureGame
3 import Testing
4
5 /// A stand-in for FireworksClient that returns canned replies and records
6 /// the conversation, so the game logic can be tested without network access.
7 final class MockClient: ChatCompleting, @unchecked Sendable {
8 private(set) var calls: [[ChatMessage]] = []
9 let reply: String
10 var errorToThrow: Error?
11
12 init(reply: String) { self.reply = reply }
13
14 func chat(messages: [ChatMessage]) async throws -> String {
15 calls.append(messages)
16 if let errorToThrow { throw errorToThrow }
17 return reply
18 }
19 }
20
21 @Suite("Game Tests")
22 struct GameTests {
23 private let story = "You are a test Game Master."
24
25 @Test("Constructor seeds the system prompt")
26 func systemPromptSeeded() {
27 let mock = MockClient(reply: "Scene.")
28 let game = Game(client: mock, storyText: story)
29 #expect(game.messages.count == 1)
30 #expect(game.messages[0].role == .system)
31 #expect(game.messages[0].content == story)
32 }
33
34 @Test("startAdventure resets history")
35 func startAdventure() async throws {
36 let mock = MockClient(reply: "Opening scene.")
37 let game = Game(client: mock, storyText: story)
38 _ = try await game.takeTurn("look around")
39 let reply = try await game.startAdventure()
40
41 #expect(reply == "Opening scene.")
42 // After restart: system + "Start the adventure." + opening
43 #expect(game.messages.count == 3)
44 #expect(game.messages[1] ==
45 ChatMessage(role: .user,
46 content: "Start the adventure."))
47 #expect(game.messages[2] ==
48 ChatMessage(role: .assistant,
49 content: "Opening scene."))
50 // The opening call should not include the earlier turn.
51 #expect(mock.calls.last?.count == 2)
52 }
53
54 @Test("takeTurn appends user and assistant messages")
55 func takeTurnBuildsHistory() async throws {
56 let mock = MockClient(reply: "A scene.")
57 let game = Game(client: mock, storyText: story)
58
59 let first = try await game.takeTurn("go north")
60 #expect(first == "A scene.")
61 let second = try await game.takeTurn("open the door")
62 #expect(second == "A scene.")
63
64 #expect(game.messages.map(\.role) ==
65 [.system, .user, .assistant, .user, .assistant])
66 // The model sees the whole history on the second call:
67 // system, the first exchange, and the user message about
68 // to be answered.
69 #expect(mock.calls[1].count == 4)
70 }
71
72 @Test("takeTurn removes the failed user message")
73 func failedTurnRollsBack() async throws {
74 let mock = MockClient(reply: "ok")
75 mock.errorToThrow =
76 FireworksClient.APIError(message: "boom")
77 let game = Game(client: mock, storyText: story)
78
79 await #expect(throws: (any Error).self) {
80 _ = try await game.takeTurn("doomed action")
81 }
82 // History is unchanged: the failed user message was popped.
83 #expect(game.messages.count == 1)
84 }
85 }
The test file also has suites that check that loadStory() finds story.txt in the current directory and that a ChatMessage encodes to the {"role": ..., "content": ...} shape the API wants. Running them (timing output removed):
1 $ swift test
2 ✔ Test "ChatMessage encodes in the OpenAI wire format" passed
3 ✔ Test "Constructor seeds the system prompt" passed
4 ✔ Test "takeTurn appends user and assistant messages" passed
5 ✔ Test "startAdventure resets history" passed
6 ✔ Test "takeTurn removes the failed user message" passed
7 ✔ Test "loadStory finds story.txt in the current directory" passed
8 ✔ Test run with 6 tests in 3 suites passed
One of these tests caught my own mistake. I first asserted that the second call sends three messages, and the test failed with four. The history already holds the user message about to be answered, so the model sees the system prompt, the first exchange, and the new user turn. This count is easy to get wrong, and a test is the cheapest way to pin it down.
Error Handling and Costs
FireworksClient reports a missing key, a non 200 status, and an empty completion through the same APIError type, so the game loop needs one catch per call site.
Costs matter even for a small game on a paid API. Each turn resends the full conversation, so the prompt for turn
holds about
exchanges. If each exchange averages
tokens, a session of
turns sends about

prompt tokens, which grows as
. To keep costs down, ask for short scenes in story.txt, restart instead of dragging out a stale story, and set max_tokens.
Text Adventure Game Wrap Up
This chapter covered the path from a prompt file to a running game: an OpenAI compatible client in under one hundred lines, a game class that owns the message history, a readLine loop, and tests that run offline against a mock. The only Fireworks specific parts are the endpoint URL, the model name, and the key’s environment variable. Change those three strings and the same code works with DeepSeek, OpenAI, or a local server that serves the chat completions API.
Some ideas for extending the project: ask the Game Master in story.txt to end scenes with a JSON block for inventory and health, then parse it into Swift state. Add streaming so prose appears token by token. Save the conversation history to a file so the player can resume later. Each change touches one type, because the protocol keeps the client and the game separate.