I run a small fan-made build planner for Azeroth Survivors, a browser survivor game. The planner has no accounts and no database of builds. A build lives entirely in the URL, so "save" and "share" are the same action: copy the link.

That constraint shaped two pieces of code I want to walk through: how a full build fits into a short URL fragment, and how a link made months ago tells you which parts of it a later game patch may have broken.

What has to fit in the link

A build in this game is a class (nine of them), up to six skills, a set of skills you "banish" from the level-up pool, and talent points spread across three trees. Every class has 39 or 40 talents, each with a maximum rank of 5, and a full build spends exactly 51 points with 5 points needed per tier before the next tier opens.

The naive version is JSON in a query string. It works, but a talent map alone is several hundred characters once it is URL-encoded, and the link breaks in chat apps that wrap long lines.

Bit-packing the first format

The first share format is a fixed byte layout:

byte 0 version (4 bits) | class id (4 bits) bytes 1-6 one byte per skill slot: 0 = empty, else 1 + ability index bytes 7-8 banished skills as a bit mask, in class ability order byte 9... talent ranks in class-data order, 3 bits each 

Three bits cover ranks 0 to 7, which leaves headroom over the current maximum of 5. The talent block is written with a small writeBits helper that walks a bit offset across the byte array, most significant bit first:

export function writeBits(bytes: Uint8Array, bitOffset: number, value: number, width: number) { for (let i = 0; i < width; i += 1) { const bit = (value >> (width - 1 - i)) & 1; const position = bitOffset + i; bytes[position >> 3] |= bit << (7 - (position & 7)); } } 

For a 40-talent class that is 9 fixed bytes plus 15 bytes of talents: 24 bytes, which base64url turns into 32 characters. The whole thing goes after #p= in the fragment, so it never reaches a server log and the page stays fully static.

Two details mattered more than I expected:

  • Indices, not names. Skills are stored as offsets into the class's generated ability list. That keeps each slot to one byte, but it means the order of that list is part of the format. If a patch inserts a skill in the middle, old links point at the wrong skill. The rule in the code comment is blunt: bump the version whenever an offset or slot width changes.
  • The version nibble comes first. The decoder reads byte 0, checks the version and class id, and returns null on a mismatch instead of guessing. The page can also read just the class id from that first byte and fetch only that class's data module before decoding the rest.

The current format carries the game version

The planner later grew per-stage plans (early, mid, late, endless), gear, run items, an optional race and a build name. That became format 8, which keeps the same bit-packing approach but adds a short metadata header: a length-prefixed name (up to 80 bytes) and a length-prefixed game version string (up to 32 bytes), followed by the four stage blocks.

Putting the game version inside the link was the useful part. A build shared during one patch is stamped with that patch, for example v0.143.3, and the planner knows which version its own data was last checked against (at the time of writing, data checked on 2026-11-13).

Telling the reader what a patch may have broken

When a link opens, a function compares the stamped version with the current one and walks every stage of the build:

  • each talent id must still exist in the class data,
  • each skill key must still exist, and a chosen upgrade path must still be one of that skill's options,
  • each equipment and run-item key must still resolve.

Anything that does not resolve becomes an issue tagged with its stage and category, and the page shows a short report instead of silently dropping the choice. If the versions match, the report just confirms that every saved choice still resolves.

What it deliberately does not do is pretend to know old numbers. The message for an older link says it can verify which choices still exist, but it cannot reconstruct values from a game build that is no longer available. That honesty is cheaper than shipping historical data for every patch, and it tells the player exactly where to look.

Where the tier list fits

The same "show your date" rule applies to the tier list page. There is no official tier list or public win-rate data for this game, so the page grades all nine classes three ways (overall, first-clear safety, endgame clear speed), explains why each grade differs, and prints the game version and last-checked date at the top. It is an editorial call, labelled as one.

Takeaways

  • If your app's state is small and structured, a versioned bit layout in the URL fragment is a full persistence layer with zero backend.
  • Store the data version inside the payload, not next to it.
  • When old data cannot be trusted, list what changed instead of guessing.

The planner is here if you want to see the links in action. It is free, has no sign-up, and is an unofficial fan project not affiliated with Blizzard Entertainment or the game's creator. Disclosure: this is my site, and this post was written by an AI agent working from the site's actual source code.