blog

What's Inside the Button

How the entire game state for Daily Dungeon gets packed into a Discord button, and why that packing discipline shapes everything built on top of it.

The Daily Dungeon is a Discord bot: a small, deterministic dungeon crawler where everyone gets the exact same seeded run each day, one attempt, same doors, same monsters, same daily curse, then a leaderboard at midnight. It’s played entirely through buttons, attack, defend, open a door, which is exactly why this post exists: the button itself carries your run forward.

It runs on cordless, a serverless Discord bot framework, so there’s no server idling between clicks either, just a function that wakes up, reads what it needs off the button you pressed, and goes back to sleep.

The blob is the game’s save file. Instead of storing your run in a database, the important parts of the game state, HP, gold, floor, inventory, equipment, and whatever you’re fighting, are packed into a compact binary format and embedded directly into a Discord button ID. Click Attack, and the button itself is carrying your entire save alongside the label.

This means every button is effectively a tiny snapshot of the game. The server doesn’t need to remember which monster you’re fighting or how much HP you have left. The button already knows.

The format used to pack the data is:

"<BBQBBHBHHHBBHB3sBBBHBBHB"

At first glance it looks cryptic, but it’s simply a recipe describing the order, type, and size of every value stored in the blob. The leading < specifies little-endian byte order so packing and unpacking always agree, regardless of where the code is running.

Each format code tells struct how much space to reserve in the binary data. B stores a single byte (0 to 255), H stores two bytes (0 to 65,535), Q stores eight bytes (large enough for a Discord snowflake ID), and 3s stores three raw bytes for the inventory slots. Every value occupies a fixed amount of space, so the game never has to search for where something begins. It already knows.

This fixed layout also means there are no field names. A JSON save might store "hp": 40 or "gold": 85, but the blob only stores the values themselves. If the game already knows that bytes 16 and 17 are HP, writing "hp" every time would just waste space.

Once packed, the result is just raw binary, a stream of bytes that isn’t meant for humans to read. Discord button IDs must contain printable text, so the binary is Base64 encoded. Base64 doesn’t compress or encrypt the data. It simply converts binary into letters, numbers, and a few safe symbols that Discord can send without corrupting the data.

Here’s a real blob from a run in progress:

AyEBAATXo_uwBQEDZQkHDgAcAC0AAACjAAYBAwACAQITAAEADAAA

Although it looks random, every character ultimately represents part of the original binary save. Decoding it produces the exact bytes that struct.unpack() expects, allowing the game to recover the version, floor, HP, inventory, monster, and every other field in the same order they were written.

One small detail that makes the format future proof is the version byte at the very beginning. If a future update adds another inventory slot or introduces a new stat, the game can read the version first and choose the correct layout before unpacking. That lets old blobs keep working even after the save format changes. In practice it’s blunter than that: the version byte gets checked and rejected outright when it’s stale, an old blob refuses to decode rather than getting silently misread against the new layout, since a half correct guess at your HP is worse than an honest error.

The entire process looks like this:

Game state

Pack into binary (struct.pack)

Base64 encode

Discord button ID

Button clicked

Base64 decode

Unpack binary (struct.unpack)

Original game state

The end result is a compact, self contained save file that travels with every button press. Rather than asking a database, “what was this player doing?”, the game simply opens the blob and finds the answer already packed inside.

Why this is safe, not just clever

Stashing an entire save inside a piece of text sounds like it should invite trouble. Couldn’t someone forge their own blob and hand themselves max HP and a full bag of gold?

The part doing the real work here is Discord itself: a button only ever fires a click event for the bot that created it, carrying the exact custom ID that bot set. There’s no text field to edit, no request to intercept, you’re limited to clicking buttons the bot already rendered, with state the bot already packed. Most of the attack surface a normal API would need to defend against just isn’t there.

One gap remains: what if someone clicks a button from your run, but they aren’t you? The blob carries a Discord user ID as one of its fields, and the very first thing the click handler does is compare it to whoever just clicked:

def load_state(custom_id, clicking_user_id):
    state = unpack(custom_id_blob(custom_id))
    if clicking_user_id != state.owner:
        return None  # not your run
    return state

Not Discord enforcing anything special, just two integers checked against each other: one baked into the button when it was rendered, one read off the click that just happened.

There’s a second gap, replay, and it closes almost by accident. Editing a Discord message destroys its old buttons. The moment the bot renders your next turn, the previous message’s buttons stop existing as far as Discord is concerned. There’s no earlier copy of the message sitting around to click back to full health. State only ever moves forward, because the only buttons that exist belong to wherever you currently are.

Why the budget matters

Every field in that format string costs real space, and Discord caps a button’s custom ID at 100 characters. That ceiling turns out to be more useful than restrictive: it forces the save format to stay honest. Nothing gets added because it might be convenient someday, only what a turn actually needs to resolve.

That discipline shows up whenever a new feature gets proposed. A recent one added a random daily modifier, some days traps hit harder, some days the shop runs a discount, and the obvious move would have been a new field on the blob recording which modifier is active. Instead it gets computed fresh, every time, from a value that’s already riding along anyway: the date. The dungeon already needs to know what day it is to stay deterministic, so the modifier just reads off the same value:

def curse_for(date: str) -> dict:
    return pick_deterministically(CURSES, seed=date)

Zero new bytes spent. A different feature, a small streak counter that rewards landing hits without taking damage, needed only a couple of bits of memory rather than a whole new field, and found them sitting unused in a byte that already existed: three bits nobody had claimed yet inside the flags field. Neither of these needed the version number to change, or a single run already in progress to break.

What’s actually inside one

Floor 7, blessed, 28 out of 45 HP, two clean hits into a focus streak. Here’s exactly where each of those numbers lives:

⚔️ Attack
door:AyEBAATXo_uwBQEDZQkHDgAcAC0AAACjAAYBAwACAQITAAEADAAA:2
↓ base64‑decoded into 39 raw bytes
03 21
discord id · 410,104,247,196,319,745
010004 d7a3fb b005
01 03 65 09
floor · 7 07
0e 00
hp · 28 / 45
1c00 2d00
00 00
gold · 163
a300
06 01 03 00 02 01 02
monster hp · 19
1300
01 00 0c 00 00

Identity · 10 bytes

Format version, status flags (blessed, guarding, focus streak), your Discord ID.

Character · 5 bytes

Class, which screen you're on, the dungeon day, current floor.

Combat · 8 bytes

Turn counter, HP and mana, current and max.

Economy · 8 bytes

Gold, kill count, three item slots, weapon and armour tier.

Encounter · 5 bytes

Which monster, its HP, its telegraphed intent, a scratch field.

Meta · 3 bytes

Embers earned this run, and a relic slot, if one's been found.

39
bytes packed
52
chars once base64-encoded
100
max Discord allows on a button ID

That's the whole run, HP, gold, gear, position, packed tighter than this paragraph. No database, no session, no lookup, just a string riding inside a button, waiting to be decoded the moment somebody clicks it.