This tutorial builds a text-based adventure game using dsprrr. The AI handles narrative generation, NPC dialogue, and action resolution while you control the game framework and player state.
Why This Matters Beyond Games
The patterns in this tutorial directly apply to production AI systems:
Customer service agents: Like NPCs, they maintain conversation context, respond appropriately to user mood, and escalate when needed. The
dialogue_sigpattern—tracking relationship history, detecting sentiment, and generating contextual responses—is exactly how you’d build an intelligent support bot.Interactive documentation: Imagine a technical assistant that remembers what you’ve tried, suggests next steps based on your skill level, and adapts explanations to your context. That’s the
scene_sigpattern: generate options based on user state and history.Workflow orchestration: The action resolution system—evaluating whether an action succeeds based on context, applying consequences, and updating state—maps directly to approval workflows, form validation, and multi-step processes.
The game framing makes these patterns concrete and testable. Once you understand how to build a coherent NPC that remembers past conversations, building a customer service agent that does the same is straightforward.
This tutorial is adapted from the DSPy text adventure tutorial by the DSPy team.
We’ll start with the simplest approach—plain lists—then show how S7 adds structure as the project grows.
What You’ll Build
- Dynamic scene generation with branching narratives
- AI-powered NPC conversations that remember context
- Action resolution with skill checks and consequences
- Inventory and character progression
- Save/load game functionality
Part 1: The Simple Approach
For most R projects, plain lists and functions are all you need.
Game State as Lists
# Player is just a list
create_player <- function(name) {
list(
name = name,
health = 100L,
level = 1L,
experience = 0L,
inventory = character(),
skills = list(
strength = 10L,
intelligence = 10L,
charisma = 10L,
stealth = 10L
)
)
}
# Game context is also a list
create_context <- function() {
list(
location = "Village Square",
story_progress = 0L,
visited = character(),
npcs_met = character(),
recent_actions = character()
)
}
# Bundle into game state
create_game <- function(player) {
list(
player = player,
context = create_context(),
running = TRUE
)
}Helper Functions
# Modify player state
add_item <- function(player, item) {
player$inventory <- c(player$inventory, item)
cli_alert_success("Added {.val {item}} to inventory!")
player
}
take_damage <- function(player, amount) {
player$health <- max(0L, player$health - amount)
cli_alert_danger("Took {amount} damage! HP: {player$health}/100")
player
}
heal <- function(player, amount) {
player$health <- min(100L, player$health + amount)
cli_alert_success("Healed {amount} HP! HP: {player$health}/100")
player
}
gain_xp <- function(player, amount) {
player$experience <- player$experience + amount
new_level <- 1L + (player$experience %/% 100L)
if (new_level > player$level) {
cli_alert_success("Level up! Now level {new_level}!")
player$level <- new_level
}
player
}
is_alive <- function(player) {
player$health > 0L
}
# Modify context
visit_location <- function(context, location) {
context$visited <- unique(c(context$visited, context$location))
context$location <- location
context$story_progress <- context$story_progress + 1L
cli_alert_info("Traveled to {.strong {location}}")
context
}
record_action <- function(context, action) {
context$recent_actions <- c(tail(context$recent_actions, 4L), action)
context
}AI Signatures
This is where dsprrr comes in—define clear contracts for each AI task. Instead of writing prompt strings, we declare what information goes in and what structure comes out. This separation matters: when you want to improve the AI’s scene descriptions, you tweak the signature’s instructions rather than hunting through string templates.
Each signature serves a distinct purpose in the game loop:
Scene generation takes the current game state and
produces a rich description with available actions. Notice the
atmosphere enum—constraining outputs to specific values
makes downstream logic predictable.
scene_sig <- signature(
inputs = list(
input("location", "Current location name"),
input("player_info", "Player stats summary"),
input("story_progress", "Story progress 0-10"),
input("recent_actions", "Last few player actions"),
input("visited_before", "Whether location was visited")
),
output_type = type_object(
description = type_string("2-3 sentence scene description"),
actions = type_array(type_string(), "4-6 possible actions"),
npcs = type_array(type_string(), "NPCs present"),
items = type_array(type_string(), "Visible items"),
atmosphere = type_enum(c("peaceful", "tense", "mysterious", "dangerous"))
),
instructions = "You are a game master for a fantasy text adventure.
Create immersive scenes with interesting choices."
)NPC dialogue handles conversations. The
relationship input (“new” or “returning”) lets the AI
adjust tone—a merchant who recognizes you might offer better prices. The
structured output ensures we can programmatically handle items changing
hands.
dialogue_sig <- signature(
inputs = list(
input("npc_name", "NPC name"),
input("npc_role", "NPC role and personality"),
input("player_says", "Player's action or words"),
input("relationship", "new or returning"),
input("context", "Location and story context")
),
output_type = type_object(
response = type_string("NPC dialogue (1-3 sentences)"),
mood = type_enum(c("friendly", "neutral", "suspicious", "hostile")),
gives_item = type_string("Item given, or 'none'")
),
instructions = "You are an NPC in a fantasy RPG. Stay in character."
)Action resolution determines outcomes. Rather than
rolling dice, we give the AI context (skill level, difficulty) and let
it generate both the success/failure boolean and a narrative
explanation. The structured output (damage_taken,
item_gained, experience) lets us update game
state automatically.
action_sig <- signature(
inputs = list(
input("action", "What player attempts"),
input("relevant_skill", "Which skill applies"),
input("skill_value", "Player's skill value"),
input("difficulty", "easy/medium/hard"),
input("context", "Relevant circumstances")
),
output_type = type_object(
success = type_boolean(),
outcome = type_string("2-3 sentence result"),
damage_taken = type_integer("Damage to player"),
item_gained = type_string("Item earned, or 'none'"),
experience = type_integer("XP earned 0-25")
),
instructions = "Resolve actions fairly but dramatically."
)Create Modules
Each signature becomes a module. We use
type = "chain_of_thought" which adds a “reasoning” field to
the output—the model explains its thinking before giving the final
answer. This improves output quality, especially for complex decisions
like whether an action should succeed.
Game Logic
The core pattern: take game state, generate AI content, apply
effects, repeat. Each function handles one
concern—player_summary formats state for the AI,
detect_skill maps player intent to game mechanics, and the
generate_*/resolve_* functions orchestrate LLM
calls.
player_summary <- function(player) {
sprintf(
"Level %d %s | HP:%d | STR:%d INT:%d CHA:%d STL:%d | Items: %s",
player$level, player$name, player$health,
player$skills$strength, player$skills$intelligence,
player$skills$charisma, player$skills$stealth,
if (length(player$inventory) > 0)
paste(player$inventory, collapse = ", ")
else "none"
)
}
detect_skill <- function(action) {
action_lower <- tolower(action)
if (grepl("fight|attack|force", action_lower)) "strength"
else if (grepl("sneak|hide|steal", action_lower)) "stealth"
else if (grepl("persuade|convince|charm", action_lower)) "charisma"
else "intelligence"
}
generate_scene <- function(game, modules) {
run(
modules$scene,
location = game$context$location,
player_info = player_summary(game$player),
story_progress = as.character(game$context$story_progress),
recent_actions = paste(game$context$recent_actions, collapse = " -> "),
visited_before = game$context$location %in% game$context$visited,
.llm = modules$llm
)
}
resolve_action <- function(game, action, modules) {
skill <- detect_skill(action)
result <- run(
modules$action,
action = action,
relevant_skill = skill,
skill_value = as.character(game$player$skills[[skill]]),
difficulty = "medium",
context = game$context$location,
.llm = modules$llm
)
# Apply effects
if (result$damage_taken > 0) {
game$player <- take_damage(game$player, result$damage_taken)
}
if (result$item_gained != "none" && nzchar(result$item_gained)) {
game$player <- add_item(game$player, result$item_gained)
}
if (result$experience > 0) {
game$player <- gain_xp(game$player, result$experience)
}
if (result$success) {
cli_alert_success(result$outcome)
} else {
cli_alert_danger(result$outcome)
}
game
}Save/Load
save_game <- function(game, filename = "savegame.json") {
jsonlite::write_json(game, filename, auto_unbox = TRUE, pretty = TRUE)
cli_alert_success("Saved to {.file {filename}}")
game
}
load_game <- function(filename = "savegame.json") {
if (!file.exists(filename)) {
cli_alert_danger("Save file not found")
return(NULL)
}
game <- jsonlite::read_json(filename, simplifyVector = TRUE)
cli_alert_success("Loaded from {.file {filename}}")
game
}Minimal Game Loop
play_game <- function(llm = chat_openai()) {
modules <- create_modules(llm)
name <- readline("Character name: ")
game <- create_game(create_player(name))
repeat {
scene <- generate_scene(game, modules)
cli_h2("{game$context$location}")
cli_text(scene$description)
cat("\n")
actions <- c(scene$actions, "Save", "Quit")
for (i in seq_along(actions)) {
cli_text("{i}. {actions[i]}")
}
choice <- as.integer(readline("Choose: "))
chosen <- actions[choice]
if (chosen == "Save") {
game <- save_game(game)
next
}
if (chosen == "Quit") break
game$context <- record_action(game$context, chosen)
game <- resolve_action(game, chosen, modules)
if (!is_alive(game$player)) {
cli_h1("GAME OVER")
break
}
}
invisible(game)
}This works. For a quick prototype or personal project, you’re done.
Part 2: Adding Structure with S7
As projects grow, plain lists show their limits:
- No validation (what if health goes negative?)
- No documentation (which fields exist?)
- No type safety (is
levelan integer or character?) - Hard to extend (adding new player types?)
S7 solves these while staying functional:
S7 Classes for Game State
Player <- new_class("Player",
properties = list(
name = class_character,
health = new_property(class_integer, default = 100L),
level = new_property(class_integer, default = 1L),
experience = new_property(class_integer, default = 0L),
inventory = new_property(class_character, default = character()),
skills = new_property(class_list, default = list(
strength = 10L,
intelligence = 10L,
charisma = 10L,
stealth = 10L
))
),
validator = function(self) {
if (self@health < 0L) return("health cannot be negative")
if (self@health > 100L) return("health cannot exceed 100")
if (self@level < 1L) return("level must be at least 1")
NULL
}
)
GameContext <- new_class("GameContext",
properties = list(
location = new_property(class_character, default = "Village Square"),
story_progress = new_property(class_integer, default = 0L),
visited = new_property(class_character, default = character()),
npcs_met = new_property(class_character, default = character()),
recent_actions = new_property(class_character, default = character()),
flags = new_property(class_list, default = list())
)
)
GameState <- new_class("GameState",
properties = list(
player = Player,
context = GameContext,
running = new_property(class_logical, default = TRUE)
)
)Now you get:
-
Validation:
Player(name = "X", health = -10L)throws an error - Documentation: Properties are self-describing
- Type safety: Can’t accidentally assign wrong types
-
Tooling: IDE autocomplete works with
@slots
S7 Generics for State Transitions
Instead of plain functions, use generics for extensibility:
add_item <- new_generic("add_item", "player")
method(add_item, Player) <- function(player, item) {
player@inventory <- c(player@inventory, item)
cli_alert_success("Added {.val {item}} to inventory!")
player
}
take_damage <- new_generic("take_damage", "player")
method(take_damage, Player) <- function(player, amount) {
player@health <- max(0L, player@health - as.integer(amount))
cli_alert_danger("Took {amount} damage! HP: {player@health}/100")
player
}
heal <- new_generic("heal", "player")
method(heal, Player) <- function(player, amount) {
player@health <- min(100L, player@health + as.integer(amount))
cli_alert_success("Healed {amount} HP! HP: {player@health}/100")
player
}
gain_xp <- new_generic("gain_xp", "player")
method(gain_xp, Player) <- function(player, amount) {
player@experience <- player@experience + as.integer(amount)
new_level <- 1L + (player@experience %/% 100L)
if (new_level > player@level) {
cli_alert_success("Level up! Now level {new_level}!")
player@level <- new_level
}
player
}
is_alive <- new_generic("is_alive", "player")
method(is_alive, Player) <- function(player) {
player@health > 0L
}
visit_location <- new_generic("visit_location", "context")
method(visit_location, GameContext) <- function(context, location) {
context@visited <- unique(c(context@visited, context@location))
context@location <- location
context@story_progress <- context@story_progress + 1L
cli_alert_info("Traveled to {.strong {location}}")
context
}
record_action <- new_generic("record_action", "context")
method(record_action, GameContext) <- function(context, action) {
context@recent_actions <- c(tail(context@recent_actions, 4L), action)
context
}
meet_npc <- new_generic("meet_npc", "context")
method(meet_npc, GameContext) <- function(context, npc) {
context@npcs_met <- unique(c(context@npcs_met, npc))
context
}
set_flag <- new_generic("set_flag", "context")
method(set_flag, GameContext) <- function(context, flag, value = TRUE) {
context@flags[[flag]] <- value
context
}
has_flag <- new_generic("has_flag", "context")
method(has_flag, GameContext) <- function(context, flag) {
isTRUE(context@flags[[flag]])
}Print Methods
method(print, Player) <- function(x, ...) {
cli_h3("{x@name}")
cli_text("Level {x@level} | HP: {x@health}/100 | XP: {x@experience}")
cli_text(
"STR:{x@skills$strength} INT:{x@skills$intelligence} ",
"CHA:{x@skills$charisma} STL:{x@skills$stealth}"
)
if (length(x@inventory) > 0) {
cli_text("Inventory: {.val {x@inventory}}")
}
invisible(x)
}
method(print, GameState) <- function(x, ...) {
cli_rule("{.emph MYSTIC REALM ADVENTURE}")
print(x@player)
cli_rule()
invisible(x)
}Richer Signatures
With more structure, we can add richer AI interactions:
scene_sig <- signature(
inputs = list(
input("location", "Current location name"),
input("player_info", "Player stats summary"),
input("story_progress", "Story progress 0-10"),
input("recent_actions", "Last few player actions"),
input("visited_before", "Whether location was visited")
),
output_type = type_object(
description = type_string("2-3 sentence scene description"),
actions = type_array(type_string(), "4-6 possible actions"),
npcs = type_array(type_string(), "NPCs present"),
items = type_array(type_string(), "Visible items"),
atmosphere = type_enum(c("peaceful", "tense", "mysterious", "dangerous"))
),
instructions = "You are a game master for a fantasy text adventure.
Create immersive scenes with interesting choices. Include NPC interactions
and exploration options. If visited before, note familiarity."
)
dialogue_sig <- signature(
inputs = list(
input("npc_name", "NPC name"),
input("npc_role", "NPC role and personality"),
input("player_says", "Player's action or words"),
input("relationship", "new or returning"),
input("context", "Location and story context")
),
output_type = type_object(
response = type_string("NPC dialogue (1-3 sentences)"),
action = type_string("Physical gesture or action"),
mood = type_enum(c("friendly", "neutral", "suspicious", "hostile")),
offers_quest = type_boolean(),
quest_hint = type_string("Quest description if offered"),
reveals_info = type_string("Lore revealed"),
gives_item = type_string("Item given, or 'none'")
),
instructions = "You are an NPC in a fantasy RPG. Stay in character.
Build relationships over interactions. Reveal information gradually."
)
action_sig <- signature(
inputs = list(
input("action", "What player attempts"),
input("relevant_skill", "Which skill applies"),
input("skill_value", "Player's skill value"),
input("difficulty", "easy/medium/hard/very_hard"),
input("context", "Relevant circumstances"),
input("has_useful_item", "Whether player has helpful item")
),
output_type = type_object(
success = type_boolean(),
outcome = type_string("2-3 sentence result"),
damage_taken = type_integer("Damage to player"),
item_gained = type_string("Item earned, or 'none'"),
item_lost = type_string("Item lost, or 'none'"),
experience = type_integer("XP earned 0-25"),
unlocks = type_string("New location/element unlocked"),
consequence = type_string("Lasting effect on game")
),
instructions = "Resolve actions fairly but dramatically. Higher skills
improve odds. Create interesting failures. Useful items help."
)Full Game Functions
player_summary <- function(player) {
sprintf(
"Level %d %s | HP:%d | STR:%d INT:%d CHA:%d STL:%d | Items: %s",
player@level, player@name, player@health,
player@skills$strength, player@skills$intelligence,
player@skills$charisma, player@skills$stealth,
if (length(player@inventory) > 0)
paste(player@inventory, collapse = ", ")
else "none"
)
}
detect_skill <- function(action) {
action_lower <- tolower(action)
if (grepl("fight|attack|break|force|lift", action_lower)) "strength"
else if (grepl("sneak|hide|steal|pick|shadow", action_lower)) "stealth"
else if (grepl("persuade|convince|charm|talk|negotiate", action_lower)) "charisma"
else "intelligence"
}
detect_difficulty <- function(action) {
action_lower <- tolower(action)
if (grepl("fight|battle|defeat|dangerous", action_lower)) "hard"
else if (grepl("look|examine|rest|wait", action_lower)) "easy"
else "medium"
}
has_useful_item <- function(player) {
useful <- c("torch", "rope", "sword", "key", "map", "potion", "lockpick")
any(tolower(player@inventory) %in% useful)
}
npc_role <- function(npc_name) {
roles <- list(
"Village Elder" = "wise leader who knows ancient lore",
"Merchant" = "trader interested in rare items",
"Guard" = "dutiful protector, suspicious of strangers",
"Mysterious Stranger" = "hooded figure with unknown motives",
"Blacksmith" = "crafts weapons, appreciates quality materials",
"Healer" = "kind herbalist who restores health"
)
roles[[npc_name]] %||% "mysterious character"
}Scene and Dialogue Handling
generate_scene <- function(game, modules) {
run(
modules$scene,
location = game@context@location,
player_info = player_summary(game@player),
story_progress = as.character(game@context@story_progress),
recent_actions = paste(game@context@recent_actions, collapse = " -> "),
visited_before = game@context@location %in% game@context@visited,
.llm = modules$llm
)
}
display_scene <- function(scene, context) {
emoji <- switch(scene$atmosphere,
peaceful = "\U0001F343",
tense = "\U0001F441",
mysterious = "\U0001F319",
dangerous = "\U0001F525"
)
cli_h2("{emoji} {context@location}")
cli_text(scene$description)
cat("\n")
if (length(scene$npcs) > 0 && !identical(scene$npcs, "")) {
cli_text("{.strong NPCs:} {.val {scene$npcs}}")
}
if (length(scene$items) > 0 && !identical(scene$items, "")) {
cli_text("{.strong Items:} {.emph {scene$items}}")
}
cat("\n")
cli_h3("What do you do?")
all_actions <- c(scene$actions, "Check inventory", "Save game", "Quit")
for (i in seq_along(all_actions)) {
cli_text("{i}. {all_actions[i]}")
}
cat("\n")
all_actions
}
handle_dialogue <- function(game, npc, action, modules) {
relationship <- if (npc %in% game@context@npcs_met) "returning" else "new"
response <- run(
modules$dialogue,
npc_name = npc,
npc_role = npc_role(npc),
player_says = action,
relationship = relationship,
context = sprintf("Location: %s, Progress: %d/10",
game@context@location, game@context@story_progress),
.llm = modules$llm
)
cat("\n")
cli_alert_info("{.strong {npc}}: \"{response$response}\"")
if (nzchar(response$action)) cli_text("{.emph ({response$action})}")
if (response$offers_quest && nzchar(response$quest_hint)) {
cli_alert_warning("Quest available: {response$quest_hint}")
}
if (nzchar(response$reveals_info)) {
cli_alert("You learned: {response$reveals_info}")
}
cat("\n")
game@context <- meet_npc(game@context, npc)
if (response$gives_item != "none" && nzchar(response$gives_item)) {
game@player <- add_item(game@player, response$gives_item)
}
game
}Action Resolution
resolve_action <- function(game, action, modules) {
skill <- detect_skill(action)
result <- run(
modules$action,
action = action,
relevant_skill = skill,
skill_value = as.character(game@player@skills[[skill]]),
difficulty = detect_difficulty(action),
context = game@context@location,
has_useful_item = has_useful_item(game@player),
.llm = modules$llm
)
cat("\n")
if (result$success) {
cli_alert_success(result$outcome)
} else {
cli_alert_danger(result$outcome)
}
if (result$damage_taken > 0) {
game@player <- take_damage(game@player, result$damage_taken)
}
if (result$item_gained != "none" && nzchar(result$item_gained)) {
game@player <- add_item(game@player, result$item_gained)
}
if (result$experience > 0) {
game@player <- gain_xp(game@player, result$experience)
cli_alert_info("+{result$experience} XP")
}
if (nzchar(result$unlocks) && result$unlocks != "none") {
cli_alert_success("Unlocked: {.val {result$unlocks}}")
}
if (grepl("go|travel|enter|head", action, ignore.case = TRUE) && result$success) {
destination <- if (nzchar(result$unlocks) && result$unlocks != "none") {
result$unlocks
} else {
sub(".*(?:go to|enter|head to) (?:the )?", "", action, ignore.case = TRUE)
}
if (nzchar(destination) && destination != action) {
game@context <- visit_location(game@context, tools::toTitleCase(destination))
}
}
cat("\n")
game
}Save/Load with S7
save_game <- function(game, filename = "savegame.json") {
save_data <- list(
player = list(
name = game@player@name,
health = game@player@health,
level = game@player@level,
experience = game@player@experience,
inventory = as.list(game@player@inventory),
skills = game@player@skills
),
context = list(
location = game@context@location,
story_progress = game@context@story_progress,
visited = as.list(game@context@visited),
npcs_met = as.list(game@context@npcs_met),
flags = game@context@flags
)
)
jsonlite::write_json(save_data, filename, auto_unbox = TRUE, pretty = TRUE)
cli_alert_success("Saved to {.file {filename}}")
game
}
load_game <- function(filename = "savegame.json") {
if (!file.exists(filename)) {
cli_alert_danger("Save file not found: {.file {filename}}")
return(NULL)
}
data <- jsonlite::read_json(filename)
player <- Player(
name = data$player$name,
health = as.integer(data$player$health),
level = as.integer(data$player$level),
experience = as.integer(data$player$experience),
inventory = as.character(unlist(data$player$inventory) %||% character()),
skills = lapply(data$player$skills, as.integer)
)
context <- GameContext(
location = data$context$location,
story_progress = as.integer(data$context$story_progress),
visited = as.character(unlist(data$context$visited) %||% character()),
npcs_met = as.character(unlist(data$context$npcs_met) %||% character()),
flags = data$context$flags %||% list()
)
cli_alert_success("Loaded from {.file {filename}}")
GameState(player = player, context = context)
}Character Creation
create_character <- function() {
cli_h1("{.emph MYSTIC REALM ADVENTURE}")
cli_h2("Character Creation")
name <- readline("Enter your character's name: ")
cli_text("\nYou have {.strong 10 bonus points} to distribute.")
cli_text("Base skills start at 10.\n")
skills <- list(strength = 10L, intelligence = 10L, charisma = 10L, stealth = 10L)
points <- 10L
for (skill in names(skills)) {
if (points > 0L) {
cli_text("Points remaining: {.val {points}}")
input <- readline(sprintf("Add to %s (0-%d): ", skill, points))
add <- min(points, max(0L, as.integer(input) %||% 0L))
skills[[skill]] <- skills[[skill]] + add
points <- points - add
}
}
player <- Player(name = name, skills = skills)
cli_alert_success("\nWelcome, {.strong {name}}!")
cli_text("Your adventure begins...\n")
GameState(player = player, context = GameContext())
}Full Game Loop
play_game <- function(llm = chat_openai()) {
modules <- create_modules(llm)
game <- create_character()
repeat {
print(game)
scene <- generate_scene(game, modules)
actions <- display_scene(scene, game@context)
choice <- as.integer(readline("Choose (number): "))
if (is.na(choice) || choice < 1 || choice > length(actions)) {
cli_alert_warning("Invalid choice")
next
}
chosen <- actions[choice]
game@context <- record_action(game@context, chosen)
if (chosen == "Check inventory") {
if (length(game@player@inventory) == 0) {
cli_alert_info("Your inventory is empty.")
} else {
cli_h3("Inventory")
for (item in game@player@inventory) cli_li("{item}")
}
readline("Press Enter...")
next
}
if (chosen == "Save game") {
game <- save_game(game)
readline("Press Enter...")
next
}
if (chosen == "Quit") {
if (readline("Save first? (y/n): ") == "y") {
game <- save_game(game)
}
game@running <- FALSE
break
}
npc_target <- NULL
for (npc in scene$npcs) {
if (grepl(tolower(npc), tolower(chosen), fixed = TRUE)) {
npc_target <- npc
break
}
}
if (!is.null(npc_target)) {
game <- handle_dialogue(game, npc_target, chosen, modules)
} else {
game <- resolve_action(game, chosen, modules)
}
if (!is_alive(game@player)) {
cli_h1("GAME OVER")
cli_text("You have fallen...")
break
}
readline("Press Enter...")
}
cli_text("Thanks for playing!")
invisible(game)
}