Slay the Spire Daily Climb
Hobby, live on this siteReverse-engineering a game's daily challenge generator, and building something that proves it still works.
What it is
Slay the Spire has a Daily Climb: one character, three modifiers, and a fixed seed that everyone in the world gets on the same day. I wanted to know what the day’s run was without launching the game — partly to decide whether it was worth playing, mostly because the question “where does this come from?” was more interesting than the answer.
It turns out the game doesn’t ask a server what today’s daily is. It computes it locally from a single integer. So this is a page on this site that computes the same thing, from the same integer.

The browser view. Modifiers are marked positive or negative, because a daily is usually a mix and it isn’t always obvious which is which.
Finding the algorithm
The game ships as a 365MB jar. The first useful decision was not decompiling it — just listing the archive and filtering for anything named like the problem. That surfaced com/megacrit/cardcrawl/daily/ immediately, along with ModHelper and SeedHelper, which was enough to decompile about forty classes with CFR instead of forty thousand.
The generation turns out to be short enough to state completely:
day = floor(utc_unix_seconds / 86400)
rng = RandomXS128(day) // libGDX xorshift128+
character = [IRONCLAD, THE_SILENT, DEFECT, WATCHER][rng.nextInt(4)]
seed = rng.nextLong()
index = day % 5
interval = day / 5
for each pool in [starter, generic, difficulty]:
candidates = pool minus the mod excluded for this character
shuffle(candidates, java.util.Random(interval))
take candidates[index]
Everything hangs off day, which is just days since the Unix epoch in UTC. That’s also the special_seed field the game writes into every save file, which matters later.
There’s a community mod, CustomClimb, that I expected to cross-check against. It was a dead end worth mentioning: it’s from 2018, unmaintained, and reflectively reads two fields (negativeMods, cardMods) that no longer exist in the shipped game. Against a current build it would come up empty. It also never derives its seed from the date. The lesson wasn’t “don’t check other sources,” it was that a second source has to be verified too, and the game’s own save files turned out to be a far better one.
“Shuffle” is a misnomer
The word shuffle in that pseudocode is misleading, and it’s the thing I got wrong first.
Collections.shuffle(list, new java.util.Random(interval)) takes a seeded generator. Java pins down both the LCG and the shuffle algorithm exactly, so that call produces a bit-identical permutation on every machine, on every platform, forever. Nothing about a daily varies per player. That’s the entire point: because the run seed is derived from the date, every player gets the same map, the same ? room outcomes, the same card rewards and the same elite HP rolls, which is what makes a leaderboard of daily scores mean anything.
So why shuffle at all? Because the obvious alternative, pool[day % size], would be visibly cyclic — the same modifier every nine days like clockwork, and the whole three-modifier combination repeating every 252 days. Re-permuting every five days and walking indices 0–4 through it produces a sequence that feels unpredictable while remaining a pure function of the date.
Two details in that code are load-bearing and easy to miss. The candidate list is built by iterating a Java HashMap, so the pre-shuffle order is hash-bucket order, not insertion order — reproducing it means reproducing String.hashCode and Java 8’s bucket layout. And the exclusion filter runs before the shuffle, so a character whose colored-card modifier is removed gets an 11-element list instead of 12, which shifts the entire permutation.
The e-ink version
The main reason this exists as a page rather than a script is that I have a TRMNL — a small e-ink display — and its official plugin will screenshot an arbitrary URL. So there’s a second view built for a 1-bit panel at 800×480.

The TRMNL view. Positive and negative are carried by a +/− glyph rather than color, since color doesn’t survive the trip to a monochrome panel.
Designing for that panel is mostly about giving up things. No grays, no hairlines, no color-coded meaning, and nothing that depends on subpixel rendering. The failures were more interesting than the rules:
- My font stack named Georgia, which doesn’t exist on Linux, and TRMNL renders on Linux — so the panel was silently falling through to whatever default serif it had. Naming real Linux faces made the output predictable instead of accidental.
- The seed was rendered in a serif, which uses old-style figures where
3and9drop below the baseline. A 13-character alphanumeric code bouncing above and below the line, in italic, at 13px, was the least readable thing on the screen. It’s monospace now. - The layout had 14px of headroom on the worst possible day, which I only found by computing the longest modifier combination the game can produce (Cursed Run + Hoarder + Terminal, 337 characters, which lands on 2027-04-06) and rendering that specific day. Then I re-rendered it in a wider fallback serif, on the theory that I don’t actually control which font TRMNL resolves, and it clipped. The type is slightly smaller now so the worst case survives the widest plausible font.
There’s a JSON endpoint too, at /spire-daily.json, with a ?start= / ?end= range form. It exists mostly to serve the verification loop below, but it’s the thing to point anything else at.
Proving it still works
This is the part I’d keep if I threw away the rest.
An algorithm reconstructed by decompiling a game is only correct until the game updates. The character and seed derivation are stable; the modifier pools are exactly the kind of thing a patch would quietly change. A reimplementation like this doesn’t fail loudly — it just starts being confidently wrong.
The fix was sitting in the save folder. Every daily run the game records includes what it actually used: special_seed, character_chosen, seed_played, and daily_mods. That’s ground truth, written by the game itself, and it grows every time I play one. Validating the original implementation against 21 played runs matched all 21 exactly — character, all three modifiers, and the 64-bit seed to the digit.
So a script runs weekly, reads every saved daily, asks the deployed endpoint what it thinks those days were, and compares. Checking the live endpoint rather than a local copy is deliberate; it tests the thing that’s actually serving, and it caught a genuine bug the local version passed.
The remaining problem was that I don’t use notifications, so a failing check would fail silently forever. So the verifier posts its result back to the site, and both views carry a line reporting it:

The self-check, deliberately failed. It also turns red if the verifier simply stops reporting for three weeks, because a check that quietly died looks identical to a check that’s passing.
Which means the drift signal shows up on the screen I already look at every day, without asking me to remember anything.
The stack, and one bug worth keeping
A single Cloudflare Worker, roughly 300 lines, no dependencies and no stored data. Because the daily is a pure function of the date, there’s no database, no cron job, no precomputed table with an expiry horizon, and nothing to rebuild when the rest of the site deploys. The port to JavaScript needed BigInt throughout — xorshift128+ is full 64-bit arithmetic and Java’s LCG multiply overflows a double — plus Math.imul to reproduce Java’s 32-bit signed hash wraparound.
The bug I’d point at: I cached the HTML for 24 hours, which is correct for content that only changes at midnight UTC — except the page contains a countdown to that rollover. A page fetched just after midnight would keep insisting 23 hours remained all day. The JSON has the same lifetime and is fine, because it reports an absolute timestamp rather than a relative one. The HTML now caches for five minutes; the JSON still caches until the daily actually changes.
The whole thing was built with Claude Code, which is also how the decompiled classes got read. Handing it a 365MB jar and having it come back with the class list worth looking at was the part that turned this from a weekend into an evening.
Where it runs
At mattwarden.com/spire-daily, which is public — there’s nothing personal in it, and the answer is the same for everyone playing that day. The e-ink view is at /spire-daily/trmnl and looks strange in a browser, which is the point.