I run a small fan wiki for a horror game, and one of its pages is a challenge tracker: 36 story-mode challenges across six chapters, each one a checkbox. The game's own challenge tracking was unreliable at launch, so a record players control themselves, and can share with friends, seemed worth building.
The constraints were simple. The site is a static export on a CDN, with no database and no accounts. Progress has to survive a reload, and sending your board to a friend has to be one link.
36 booleans are a number
Each challenge gets a fixed position in one ordered list. A finished challenge sets the bit at that position, and the whole board becomes one integer below 2^36.
const ORDER = CHAPTERS.flatMap((c) => c.challenges.map((ch) => `${c.slug}:${ch.n}`)); // 36, stable
const idx = new Map(ORDER.map((k, i) => [k, i]));
function encode(done: Set<string>): string {
let n = 0;
for (const k of done) {
const i = idx.get(k);
if (i !== undefined) n += 2 ** i;
}
return n.toString(36);
}
Base 36 makes it short. A completely finished board is vkhsvlr, seven characters, so the share link is /challenge-tracker?d=vkhsvlr.
Two details cost me time:
-
Don't use bitwise operators.
|and<<work on 32-bit integers, and bits 32 to 35 silently disappear. With2 ** iandMath.floor(n / 2 ** i) % 2, plain Number arithmetic stays exact, since 2^36 is far belowNumber.MAX_SAFE_INTEGER. -
Validate before decoding.
parseInthappily reads junk. Anything that isn't 1–7 base-36 characters, or is at least 2^36, decodes to an empty set rather than a wrong board.
function decode(s: string): Set<string> {
const out = new Set<string>();
if (!/^[0-9a-z]{1,7}$/.test(s)) return out;
const n = parseInt(s, 36);
if (!Number.isFinite(n) || n >= 2 ** ORDER.length) return out;
ORDER.forEach((k, i) => { if (Math.floor(n / 2 ** i) % 2 === 1) out.add(k); });
return out;
}
Two places, one rule
State lives in two places: localStorage for your own record, and ?d= in the address for sharing. The rule is that a link in the address wins on load. If a friend sends you their board, you should see their board, not yours.
That creates one false positive: reloading your own page with ?d= in it would claim "this is a shared board". So the check compares canonical encodings (encode(decode(saved)) !== encode(board)), which also treats ?d=01 and a saved 1 as the same board.
Every change goes through one function that updates React state, writes localStorage, and calls history.replaceState. That keeps the back button sane and gives one place to report whether storage actually worked. Private browsing can throw on setItem, and when it does the page tells you to keep the link, because that is the only copy, instead of pretending it saved.
Hydration
The page is prerendered as an empty board, so the URL and storage can only be read after hydration. That read happens once, in an effect. It's the one case where setting state from an effect is correct, and the lint rule has to be told so explicitly.
Was it worth it?
The encoding and storage part is about 50 lines, it costs nothing to host, and the share link fits in a chat message. If you want to see it working, the tracker is at thehalloweengame.wiki/challenge-tracker.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.