Skip to content

Game Saves ​

game:saves lets a personal plugin read the user's own local save files for a supported game. The Dissent desktop app reads the files on the user's computer and hands the plugin an allow-listed projection: every field that reaches a plugin is named on purpose, and a game update that adds data to its saves adds nothing to what plugins receive.

The rule the projection follows: a plugin receives only what the game has already shown the player. No RNG seeds, no upcoming fights or events, no later acts, no Steam IDs, no file paths. Nothing is uploaded — the data goes from the file to your frame.

Methods ​

javascript
await Dissent.gameSaves.currentRun(game)
await Dissent.gameSaves.runs(game, { limit, before })
await Dissent.gameSaves.run(game, id)
await Dissent.gameSaves.profileStats(game)
const off = Dissent.gameSaves.onChange(({ game }) => { /* pull again */ })

game is a catalog id. Supported today: slay-the-spire-2 (Windows).

Statuses — answers, never exceptions ​

Every answer is an object with a status. Only ok carries data. Render the others as plain sentences rather than an empty panel.

statusMeaningSay something like
okThe answer's data follows—
desktop_onlyNot running in the desktop app (web, mobile)"Open Dissent desktop to see your runs."
no_game_dataNo save folder for this game on this computer"No saves found on this computer."
no_current_runcurrentRun: no run in progress"No run in progress."
unsupported_versionThe game changed its save format; the desktop app needs an update"Dissent desktop needs an update to read this game's saves."
unsupported_coopcurrentRun: a co-op run is in progress and the desktop app is older than v1.2.239. Current versions read co-op runs"Update Dissent desktop to see this co-op run."
unreadableThe file was being written; retry on the next cue"Couldn't read the save — it will retry."
not_foundrun: no finished run with that id"That run is no longer in your history."
unsupported_gameNo adapter for that catalog id—

Freshness ​

After a plugin's first game.saves call, the desktop app watches the save folder and emits game.saves.changed within about two seconds of the game rewriting a file. Treat it as a cue to pull again, not as data.

As of the last room change

Slay the Spire 2 writes its save when the player enters or leaves a room — never during a fight. Say "as of entering this room" rather than implying live state.

onChange is delivered to a plugin's in-app page. Overlay panels do not receive it; re-read when the overlay opens (see Overlay Panels).

Ids ​

Ids in answers are the game's own, prefixed by kind: CARD.BASH, RELIC.BURNING_BLOOD, POTION.FIRE_POTION, ENCOUNTER.NIBBITS_WEAK, EVENT.NEOW, CHARACTER.IRONCLAD, ACT.UNDERDOCKS. Strip the prefix to look an entity up in your own data.

Old runs mention things that no longer exist

A card removed by a game patch still appears in runs played before it. Render an unknown id plainly — never a blank tile, never a crash.

Slay the Spire 2 — answer shapes ​

currentRun ​

typescript
{
  status: 'ok',
  game: 'slay-the-spire-2',
  saved_at: number,                // unix seconds
  you: number | null,              // which entry in players[] is this computer's player; null if unknown (co-op)
  character: string,               // YOUR character (player 1's when `you` is null)
  ascension: number,
  game_mode: string,               // 'standard'
  act: { index: number, id: string, boss: string },   // the CURRENT act only; its boss is on the map
  map: {
    width: number, height: number,
    start: MapNode, boss: MapNode,
    nodes: MapNode[],
    visited: { col: number, row: number }[],
  },
  players: [{                      // one per player — two or more in co-op
    player: number,                // 1, 2, … — a position, never a Steam id
    character: string,
    hp: number, max_hp: number, gold: number,
    max_energy: number, potion_slots: number,
    deck: Card[],
    relics: string[],
    potions: string[],
  }],
  floors: Floor[],                 // floors played so far
}

type MapNode = { col: number, row: number, type: string, children: { col: number, row: number }[] };
type Card = { id: string, upgrades: number, enchantment?: string };

runs — finished runs, newest first ​

typescript
{
  status: 'ok',
  game: 'slay-the-spire-2',
  runs: RunSummary[],
  skipped: number,                 // files that could not be read (e.g. another game version)
  next_before?: string | null,     // pass as `before` for the next page; null when nothing older remains
}

type RunSummary = {
  id: string,                      // digits; pass to run(id) and as `before`
  started_at: number,              // unix seconds
  run_time: number,                // seconds
  characters: string[],
  players: number,
  you: number | null,              // which player (1, 2, …) was this computer's; null in co-op if unknown
  ascension: number,
  game_mode: string,
  win: boolean,
  abandoned: boolean,
  floors: number,
  killed_by: string | null,        // 'ENCOUNTER.…' or 'EVENT.…'
  build: string,                   // game build that wrote the run, e.g. 'v0.99.1'
};

limit is 1–100 (default 20). To page, pass next_before as before; stop when it is null.

Paging past unreadable files

limit counts files before unreadable ones are dropped into skipped, so a page can come back with fewer runs than limit — or none — while older runs still exist. next_before accounts for that. Older desktop apps do not send it: then pass the last run's id, and stop on an empty page rather than starting again from the top.

run — one finished run ​

typescript
{
  status: 'ok',
  game: 'slay-the-spire-2',
  summary: RunSummary,
  acts: string[],
  players: [{ player: number, character: string, deck: Card[], relics: string[], potions: string[] }],
  floors: Floor[],
}

Players are numbered 1, 2, … in the order the game lists them; identifiers from the file are never passed through.

Floor ​

typescript
type Floor = {
  act: number,                     // 0-based
  type: string,                    // 'monster' | 'elite' | 'boss' | 'shop' | 'rest_site' | 'treasure' | 'ancient' | 'unknown' …
  rooms: { id: string | null, type: string, monsters: string[], turns: number }[],
  players: FloorPlayer[],
};

type FloorPlayer = {
  player: number,
  hp: number, max_hp: number, gold: number,
  damage_taken: number, hp_healed: number,
  max_hp_gained: number, max_hp_lost: number,
  gold_gained: number, gold_spent: number, gold_lost: number,
  card_choices: { id: string, picked: boolean }[],     // rewards — and a shop's cards for sale
  relic_choices: { id: string, picked: boolean }[],
  potion_choices: { id: string, picked: boolean }[],
  ancient_choices: { id: string, picked: boolean }[], // id UNPREFIXED, e.g. 'YUMMY_COOKIE' (a relic)
  cards_gained: string[],
  cards_removed: string[],
  cards_transformed: { from: string, to: string }[],
  upgraded_cards: string[],
  bought_relics: string[],
  potions_used: string[],
  rest_site_choices: string[],     // 'HEAL', 'SMITH', …
  event_choices: string[],        // localisation keys, e.g. 'BYRDONIS_NEST.pages.INITIAL.options.TAKE.title'
};

A shop is not a reward

On a shop floor, card_choices lists the cards that were for sale; picked means bought. Label it accordingly.

profileStats ​

typescript
{
  status: 'ok',
  game: 'slay-the-spire-2',
  cards: { id: string, picked: number, skipped: number, won: number, lost: number }[],
  characters: {
    id: string, wins: number, losses: number,
    best_streak: number, current_streak: number, max_ascension: number,
    fastest_win_secs: number, playtime_secs: number,
  }[],
}