How We Put AI Events and Characters Inside Slay the Spire 2
Our SpireTalk mod gives the Ancients and the Merchant a voice, deals, keepsakes and an ending of their own. The characters talk. The game keeps every number.
Slay the Spire 2 is a card game about climbing a tower. On the way up you meet the Ancients, old spirits who, in the base game, say one of a few fixed lines and hand you a relic. We built a mod called SpireTalk that lets them talk. You type what you want to say. They answer in character, remember you from run to run, strike deals with you, and hand out rewards. The game still decides every number.
Take Darv, the one who hoards things. He looks at your deck, notices the heavy blade you still carry, and remembers that two runs ago you called his collection junk. You get three lines per visit. If he likes you enough he may give you 50 gold or heal a fifth of your health. If he does not, nothing in your run changes.
The character is a language model. It runs on the Yumina engine, the same open-source engine behind every world on yumina.io, embedded inside the game. This post is about how the two fit together without the model ever touching the rules.
What the mod does
SpireTalk is a mod for Slay the Spire 2, written in C# for the game's Godot runtime. It gives the eight Ancients and the Merchant a voice. Each visit is three lines of conversation with suggested replies. An affection score moves a few points per line and gates the rewards. Characters propose deals that the game settles. And a character can remember you across runs.
Events talk too. When a road event in the game involves one of them, the character says a line written for that moment. When a run ends, the character who knew you best speaks on the game-over screen: the deals you kept, where you fell or how you won. One call per run, saved to your profile so you can read it again.
Under the mod sits the same engine that runs every world on yumina.io, built into a single JavaScript file of 185 KB. Each character is a Yumina world card. The model never changes anything in your run directly. It gets three decisions per line, and the game checks all three before anything happens.

The AI does more than talk. It makes the game's own tools move. The Merchant's shop gives a discount that depends on how much he likes you. Deals hook into the game's events and settle themselves. Each character hands you a keepsake relic that levels up with the friendship and takes a vow at the top tier. The conversation is the surface. Underneath, affection, deals and keepsakes are game mechanics that only exist because the character can judge what you said.

The engine runs inside the game
Slay the Spire 2 mods are .NET code. Our engine is TypeScript. We did not want to rewrite it, and we did not want to ship a browser or a native JavaScript engine inside a mod. So the mod runs the engine through Jint, a JavaScript interpreter written in C#. It is pure managed code, and nothing native ships with the mod: the engine bundle, the nine character cards and Jint itself are embedded in the mod's own DLL.
Everything crosses the boundary as JSON text. The engine never opens a network connection. The mod makes the one HTTP call to the model with the player's own key. Four functions do all the work:
var js = new Jint.Engine(o => o.Strict(false).LimitRecursion(512));
js.Execute(source, "yumina-runtime.js");
js.Execute("""
function __start(worldJson, varsJson) { ... Yumina.startSession(...) }
function __apply(worldJson, stateJson, varsJson) { ... Yumina.applyVariables(...) }
function __build(worldJson, stateJson, historyJson, userText, model) { ... Yumina.buildTurnPrompt(...) }
function __finish(worldJson, stateJson, userText, rawText) { ... Yumina.finishTurn(...) }
""");
Jint cannot await a JavaScript promise, so a turn is split into two halves around the model call. buildTurnPrompt assembles the messages. The mod sends them and waits. finishTurn parses the reply and updates the state. On yumina.io the same two halves run on our server with our billing and memory around them. In the mod there is nothing around them but the game.
The cost of running an interpreter inside a game turned out to be small. Loading the bundle takes about 260 ms, and the mod does it at the main menu, where nobody notices. Starting a character's session takes 70 ms. Building a prompt takes 30 ms. Parsing a reply takes 31 ms. The model call takes seconds, so everything else disappears into it.
A character is a normal Yumina world
Darv is a JSON file of 28 KB with the same shape as any world a creator builds in the editor. He has nine lore entries and 35 variables. The entries are his persona and his own lines from the game, in English and in Chinese. Then the rules of the scene, the affection and reward rules, the deal rules, the keepsake rules, the output format, and his greeting.
The persona reads the way you would brief an actor:
Voice: folksy and fast: "ya", "gonna", "somethin'", "shinies", "have a gander!", "whatevah". Exclamation marks, self-interruptions ("...where did I put... Oh!"), and "Nevermind nevermind!!" when you say too much. You talk like a shopkeeper who loves his customers more than his stock.
WHAT MOVES YOU (use this to judge affection_delta; + earns, - costs)
- curiosity about your collection; trading stories about odd objects; gratitude; offering to do you a favour; humour
- calling your collection junk; trying to steal; demanding the best piece; cutting you off mid-story
Of the 35 variables, 32 are read-only. They carry what the game knows: your HP and max HP, gold, act and floor, ascension, up to 14 relic names, up to 18 grouped card titles, and your potions. They also carry the relationship: how many times you have visited, how many lines you have left, the current affection and tier, which rewards are allowed right now, and whether a deal is on the table. The model reads them. If it writes to one of them, the engine drops the write and the mod logs it.
Exactly three variables are writable:
| Variable | Type | Meaning |
|---|---|---|
affection_delta |
number from -3 to 3 | how this one line changed the relationship |
choices |
list of three strings | the suggested replies shown as buttons |
action |
{ "type", "amount" } |
a reward request, which the game may refuse |
The output entry tells the model exactly how to write them, at the end of its prose:
[affection_delta: set 0]
[choices: set ["first thing to say", "second thing to say", "third thing to say"]]
[action: set {"type": "none", "amount": 0}]
This is the Yumina directive format. Every variable change in every world on yumina.io arrives the same way, as a bracketed line the engine parses and applies. The model never runs code. It writes a value, and the engine decides whether that value is allowed.
One line of dialogue, step by step
- The mod loads the card. The built-in Darv, or a player's own version from the game's AppData folder if one is there.
- It pushes the game's facts into the state. HP, gold, floor, relics, deck, affection, tier, lines left, allowed rewards, the pending deal. It also resets the three writable slots to zero, an empty list and
none. A model that stays silent cannot replay last turn's proposal. - It builds the history. One message of what the character remembers about you, written in the third person from earlier visits, then this visit's exchange word for word. The character's own old lines are never sent back.
- The engine builds the prompt. Lore entries that always send, the entries whose conditions match (the English or the Chinese canon lines, never both), the history, and the output contract. Budget: 32,000 tokens of context, 1,500 tokens of reply, temperature 0.9.
- The mod calls the model. Any OpenAI-compatible endpoint, with the player's key. The default is DeepSeek V4.1 Flash through OpenRouter. Timeout 120 seconds, one retry after two seconds on the failures that usually clear themselves. A failed line does not count against the three.
- The engine parses the reply. It strips thinking tags, finds the directive lines, refuses writes to read-only variables, applies the rest, and returns the clean text.
- The mod reads the three decisions from the state, not from the text. Then it checks them.
The game decides, the AI proposes
The affection delta the model wrote is clamped to -3 to 3 per line. Across a visit the net change is capped at 8. A repeated line, or a line under two characters, counts as 0. The score lives on a scale from -100 to 100 and the tiers are fixed: below 0 hostile, 0 to 33 stranger, 34 to 66 friendly, 67 to 99 close, 100 devoted. The model is told the tiers so it can speak differently at each one, but it cannot move you between them faster than the clamps allow.
Rewards work the same way. Before each line the mod computes the list of rewards that are allowed right now and puts it in a variable. After the line, anything the model asked for that is not on that list becomes none. The amounts are the mod's: gold 30, 50 or 75 by tier, healing 12, 20 or 25 percent of max HP, a shop discount of 10, 20 or 30 percent. One reward per visit, never on the greeting, most of them once per run per character. The reward is applied through the game's own commands, after the last speech bubble is shown.
Deals go one step further. There are seven kinds. Reach the next act. Kill an elite. Keep your HP, or your gold, above a number. Use no potion. Remove no card. Spend at a shop. The mod picks one from the character's list and sizes it. The model only receives the finished proposal, with this instruction in its lore:
Never change the terms and never invent deals of your own; when deal_offer is "none", propose nothing. The climber answers with a button, not with words. The GAME judges them, never you.
The game settles the deal from its own events. A kept deal is worth 6 affection, 50 gold and an engraving on your keepsake. The model learns the result as a word, KEPT or BROKEN, and reacts in character.


Why we did not use function calling
The obvious design is tool calling: give the model a give_gold function and let it call it. We rejected it for three reasons.
The mod supports 13 providers plus any custom endpoint, including Ollama on the player's machine. Tool calling works differently on each of them and not at all on many small models. Directive lines at the end of plain text work everywhere a chat completion works.
A tool call is still a value the model made up. The mod would have to validate the arguments exactly as it validates the directive values now. Function calling would have added a protocol without removing a check.
The third reason is that the engine already existed. The parser that reads [affection_delta: set 2] is the same parser that has read tens of millions of variable changes on yumina.io. Its edge cases were already paid for.
JSON mode had the same problems with fewer providers, and parsing numbers out of free prose was the thing we were trying to get away from.
What broke
Small models wrote the fields as prose. Instead of the directive lines, some models ended their reply with choices: ["a", "b", "c"] or affection_delta: +1 as loose text. The mod now salvages those lines and removes them from what the player sees. The engine's contract is strict. The mod's reading of the reply is forgiving.
Characters repeated their catchphrase in every reply. Darv said "Have a gander!" at the end of every line. The mod now compares each reply with the character's earlier lines from this visit and the last couple. Any shared run of 5 Chinese characters or 22 Latin letters counts as a repeat. A fragment that is 60 percent repeat gets dropped. A reply is never emptied. If everything repeats, it goes out as it came.
A silent model replayed last turn. Sometimes a model returned text without the directive lines. The previous action was still sitting in the state, so the character handed out the same reward twice. That is why the mod resets the three slots before every line.
Characters quoted themselves. When we sent earlier visits back as a transcript, the character repeated its own old lines almost word for word. Models copy whatever sits in their context. Earlier visits now become short third-person notes about what the player said, built from the last three player quotes of at most 60 characters. The character's own words from past visits are never sent.
Thinking models spent the whole budget thinking. Qwen through OpenRouter spent 10 to 20 seconds reasoning even at the lowest setting. Ollama ignores the thinking flag on its OpenAI-compatible route, so reasoning models there burned every token and returned nothing. The mod now turns thinking off with the right flag for each provider. If a provider rejects that with a 400 or 422, the mod remembers and asks for light thinking instead.
Jint cannot await. The engine's turn function was one async call. It is now two synchronous halves, and the host runs its own HTTP call in between. That change made the engine embeddable anywhere there is a JavaScript interpreter.
Rules we follow now
- The model writes values, never commands. Three writable variables, and the engine drops writes to the other 32.
- The game checks every proposal before it touches the run. Affection by line and by visit, rewards against a list the mod computed, deals the model never wrote.
- Reset proposal slots before every line. Silence must mean nothing happened.
- Never send a character its own past lines. Notes about the player, in the third person, and only the current visit verbatim.
- Be strict in the contract and forgiving in the parser. Ask for exact directive lines, then salvage what small models send anyway.
- A failed call costs the player nothing. No line is deducted, and one retry is automatic.
Try it
The mod's code is public at github.com/Cpjjason/spiretalk. It needs an API key from any OpenAI-compatible provider, or runs on a scripted fallback without one. Characters are JSON files you can override from the game's AppData folder, so you can rewrite Darv's persona, or his values, without touching the mod.
The engine bundle came out of this work. It is the same code as the Yumina engine, with the server removed, and it runs wherever a JavaScript interpreter runs. What comes next is making that bundle a normal build output so other games can embed it the same way.
FAQ
Do I need an API key?
For live conversation, yes. The mod talks to any OpenAI-compatible endpoint, including Ollama on your own machine. Without a key it falls back to a scripted set of lines in 16 languages.
Can the AI change my run?
Only through rewards the game has already allowed. The model asks. The mod checks the request against a list it computed itself and applies the amount it decided on. The game's own events settle the deals.
Which models work?
Any chat model behind an OpenAI-compatible endpoint. The default is DeepSeek V4.1 Flash through OpenRouter. Reasoning models work, with thinking turned off so the reply arrives in seconds.
Does a character remember me between runs?
If you turn it on. Each character keeps up to 80 lines of history and up to 30 short notes, written in the third person, in a file next to your save profile. You can edit the notes yourself.