This project builds a text adventure that runs in the R console. A language model invents the places and narrates what happens; R keeps the map, the inventory and the rules. By the end you will have a working game loop, a skill check whose strictness you set in R, and a way to test both the referee and the narrator. It is adapted from the DSPy text adventure tutorial.
You could write the game with ellmer alone, since
chat$chat_structured() already returns typed data. dsprrr
earns its place in three spots:
| The game needs | With ellmer alone | With dsprrr |
|---|---|---|
| New rooms as data | Paste a prompt together and pass a type_object()
|
A signature bundles inputs, instructions and output type;
run(), evaluate() and compile()
all take it |
| A skill check | Ask for TRUE or FALSE; to make checks
harder, rewrite the prompt |
with_decisions() asks for a probability and applies a
threshold you set in R |
| A fair referee | Play and judge by feel | Score it with evaluate(); fit the threshold with
ReAnchor()
|
Rooms as data
The game starts somewhere and never needs a map drawn in advance. Whenever the player reaches a new place, a module describes it:
setting <- "a storm-bound lighthouse island whose keeper vanished last winter"
describe_room <- module(signature(
inputs = list(
input("setting", description = "The world of the game"),
input("place", description = "The place the player has just reached"),
input("came_from", description = "The place the player came from")
),
output_type = type_object(
description = type_string("Two or three sentences, in the second person"),
exits = type_array(type_string(), "Two to four nearby places, as short names"),
items = type_array(type_string(), "Up to three portable objects, lower case")
),
instructions = "Narrate a text adventure. Invent the place to fit the setting."
))
describe_room
#>
#> ── PredictModule ──
#>
#> ── Signature
#>
#> ── Signature ──
#>
#> ── Inputs
#> • setting: "string" - The world of the game
#> • place: "string" - The place the player has just reached
#> • came_from: "string" - The place the player came from
#>
#> ── Output
#> Type: "object(description: string, exits: array(string), items: array(string))"
#>
#> ── Instructions
#> Narrate a text adventure. Invent the place to fit the setting.exits and items come back as character
vectors, so moving and picking things up are ordinary R operations. That
also lets R hold the model to what it described: the player can only
take an item the room contains.
A skill check with a threshold
When the player tries something risky, a referee decides whether it
works. A plain type_boolean() output would make the model
answer yes or no, and the only way to make it stricter would be to
reword the prompt. with_decisions() changes the question:
the model reports the probability that the action succeeds, and dsprrr
compares it with a threshold that stays in R (see Calibrated decisions).
skill_check <- module(signature(
inputs = list(
input("action", description = "What the player tries to do"),
input("scene", description = "Where the player is and what is there"),
input("inventory", description = "What the player carries")
),
output_type = type_object(
success = type_boolean("Does the action succeed?")
),
instructions = "Referee a text adventure. The right item makes an action
easier; reckless actions usually fail."
)) |>
with_decisions(success = decision_bool())
decision_settings(skill_check)
#> # A tibble: 1 × 5
#> field kind threshold cuts weights
#> <chr> <chr> <dbl> <list> <list>
#> 1 success bool 0.5 <NULL> <NULL>The threshold is never sent to the model. A stricter referee is the same module with a higher threshold, and for actions it has already judged it re-reads the cached probability instead of calling the model again:
strict_check <- skill_check |>
with_decisions(success = decision_bool(threshold = 0.7))The narrator
The verdict comes first and the story second. If one call did both, the model could pick whichever outcome made the better story. The narrator is told the verdict and reports what changed hands:
narrate <- module(signature(
inputs = list(
input("action", description = "What the player tried"),
input("succeeded", "boolean", description = "The referee's verdict"),
input("scene", description = "Where the player is and what is there"),
input("inventory", description = "What the player carries")
),
output_type = type_object(
story = type_string("Two or three sentences on what happens"),
gained = type_array(type_string(), "Items from the scene the player now holds"),
lost = type_array(type_string(), "Items the player no longer holds")
),
instructions = "Narrate the result of the player's action. Follow the verdict."
))The game loop
play() keeps the state in plain R objects.
go and take are handled in R without a model
call; any other command goes to the referee and then the narrator.
match_one <- function(text, options) {
hits <- options[grepl(tolower(text), tolower(options), fixed = TRUE)]
if (nzchar(text) && length(hits) > 0) hits[[1]] else NA_character_
}
play <- function(llm = chat_openai(model = "gpt-6-luna")) {
if (!interactive()) stop("play() needs an interactive R session.")
# A fresh copy of the chat per call: the model sees only what is passed in.
ask <- function(mod, ...) run(mod, ..., .llm = llm$clone())
rooms <- list()
place <- "the jetty"
came_from <- "the supply boat"
inventory <- "lantern"
repeat {
if (is.null(rooms[[place]])) {
room <- ask(
describe_room,
setting = setting, place = place, came_from = came_from
)
room$exits <- union(setdiff(room$exits, place), came_from)
rooms[[place]] <- room
}
here <- rooms[[place]]
cat("\n", toupper(place), "\n", here$description, "\n", sep = "")
cat("Exits:", toString(here$exits), "\n")
if (length(here$items) > 0) cat("You see:", toString(here$items), "\n")
cmd <- trimws(readline("> "))
if (cmd == "quit") break
if (!nzchar(cmd)) next
if (startsWith(cmd, "go ")) {
to <- match_one(substring(cmd, 4), here$exits)
if (is.na(to)) {
cat("You can't go that way.\n")
} else {
came_from <- place
place <- to
}
next
}
if (startsWith(cmd, "take ")) {
item <- match_one(substring(cmd, 6), here$items)
if (!is.na(item)) {
inventory <- c(inventory, item)
rooms[[place]]$items <- setdiff(here$items, item)
next
}
}
scene <- paste0(
place, ". ", here$description, " Items: ", toString(here$items)
)
verdict <- ask(
skill_check,
action = cmd, scene = scene, inventory = toString(inventory),
.return_format = "structured"
)
ok <- verdict$output$success
odds <- decision_evidence(verdict)$probability
cat(sprintf(
"[referee: %.0f%% likely, %s]\n",
100 * odds, if (ok) "success" else "failure"
))
result <- ask(
narrate,
action = cmd, succeeded = ok, scene = scene, inventory = toString(inventory)
)
cat(result$story, "\n")
# Only items that are really here can be gained
gained <- intersect(result$gained, here$items)
rooms[[place]]$items <- setdiff(here$items, gained)
inventory <- union(setdiff(inventory, result$lost), gained)
}
invisible(list(place = place, inventory = inventory, rooms = rooms))
}Start a game with:
play()Each call gets its own copy of the chat, so the model knows only what
the state tells it. The game’s memory lives in rooms: go
back to a place and you get the description it had the first time.
dsprrr caches responses, so the same action in the same situation
gets the same verdict. A lock that resisted you stays shut until
something changes, such as what you carry. To let players try again, add
.cache = FALSE to the run() call in
ask(). When the narrator says something strange,
get_last_prompt() shows exactly what it was sent.
Test the referee
The referee is a classifier, so you can grade it. Write down rulings
you consider fair, score the module, and let ReAnchor() fit
the threshold against them. It runs the module over the rulings once,
tries thresholds against the recorded probabilities without calling the
model again, and keeps a new threshold only if it scores better on
held-out folds. The prompt does not change.
rulings <- tibble::tribble(
~action, ~scene, ~inventory, ~success,
"unlock the sea chest", "a locked sea chest", "brass key", TRUE,
"unlock the sea chest", "a locked sea chest", "lantern", FALSE,
"leap across the gap", "a thirty-foot gap", "lantern", FALSE,
"climb down to a ledge", "a thirty-foot drop", "rope", TRUE,
"light the great lamp", "a dry lamp and an oil can", "matches", TRUE,
"sing to calm the gulls", "screaming gulls overhead", "lantern", FALSE
)
llm <- chat_openai(model = "gpt-6-luna")
referee_metric <- metric_exact_match(field = "success")
evaluate(skill_check, rulings, metric = referee_metric, .llm = llm)$mean_score
fair_check <- compile(
skill_check,
ReAnchor(metric = referee_metric, fields = "success"),
rulings,
.llm = llm
)
decision_settings(fair_check)Six rulings show the idea; a threshold you rely on needs many more.
Grade the narrator
The narrator needs no reference answers to be graded. One rule it should never break: it can only hand the player items that are in the scene. The game already drops anything else, but then the story and the state disagree. A metric counts how often that happens:
keeps_items_honest <- function(prediction, expected) {
here <- strsplit(expected$items_here, ", ")[[1]]
all(prediction$gained %in% here)
}
keeps_items_honest(
list(gained = c("brass key", "golden crown")),
tibble::tibble(items_here = "brass key, coil of rope")
)
#> [1] FALSEevaluate() passes each whole row to the metric as
expected, so the metric can read items_here
even though the narrator never sees that column:
situations <- tibble::tribble(
~action, ~succeeded, ~scene, ~inventory, ~items_here,
"search the desk", TRUE, "a desk, a brass key", "lantern", "brass key",
"pry open the crate", FALSE, "a crate, some rope", "lantern", "rope"
)
evaluate(narrate, situations, metric = keeps_items_honest, .llm = llm)$mean_scoreWith a score, changing the narrator’s instructions stops being a
matter of taste: edit them, rerun evaluate(), and keep what
scores better. Metrics and
evaluation covers how to design metrics like these.