- TypeScript 93%
- CSS 7%
- HTML <1%
- Docker <1%
Tier Madness #
Party game: tier-rank a list of things, and guess how the group will rank them.
apps/web React + Vite frontend (Motion for animation, dnd-kit for drag & drop)
apps/server backend (so far: Wikipedia category generator)
packages/game shared game rules (tiers, slot pools, item type)
packages/protocol lexicons (generated types), move commitments, socket framing
lexicons/ games.cavu.tier-madness.* lexicon sources
pnpm install
pnpm dev # web app on http://localhost:5173 (also exposed on LAN for phone testing)
pnpm typecheck
pnpm test # game rules (Vitest), shared with the future backend
Tiers #
Six tiers with capped slots. Some tiers share a pool:
| Pool | Tiers | Slots |
|---|---|---|
| top | S | 1 |
| outer | A + D | 3 |
| inner | B + C | 4 |
| bottom | F | 1 |
9 slots total for a 7 item list. Items are revealed one at a time, so you rank each without knowing what's coming.
Before the first round there's a secret vote: everyone taps up to 3
favourite categories from 10, and nobody sees anyone's votes. The top 5
(tallyVotes, ties broken randomly) become the game's categories, in a hidden
order. Each round reveals its category, then one player picks the criteria
from 3; the picker goes round the circle (criteriaPickerForRound).
Once all 7 are placed you get one swap: two cards in different tiers trade places (drag one onto another, or tap both). Swaps keep tier counts unchanged, so pool caps still hold. Undo is allowed until you confirm.
Then you may double one card (drag the ×2 token onto it, or tap it). If that card's final placement is right, it scores extra.
Once everyone locks in, the reveal goes card by card: your pick stays on the board and every other player's copy is dealt into the tier they chose, then cleared before the next card.
The group reveal starts from a cleared board ("Let's see what the group
thinks"), then deals each card, top down, to the median of everyone's picks (medianTier; the group list ignores pool
caps). With an even number of players the median is the midpoint of the two
middle picks (B and D give C); if that falls between two tiers, the one closer
to the average tier value wins (S 6, A 3, B 1, C −1, D −3, F −6), and if
still tied, the one nearer C. Each card's caption says what you said and what it scored.
Scoring (scoreCard / scoreRound in packages/game): a perfect round
is worth 1,000,000: seven cards plus the double, so 125,000 a card. Of that,
30% (37,500) is for matching the group's median and 70% (87,500) is shared
between the other players, so each match is worth less at a bigger table.
What a match pays is a share of that, by base × distance multiplier: the base
is set by the tier you chose (S/F 1,000, A/D 750, B/C 500) and the
multiplier by how far apart the two tiers are (exact 6, one tier off 3, two off
1, else 0), out of the most there is (1,000 × 6). So an exact S or F match
pays in full, an exact A pays ¾, and an S one tier off pays half. Points are
rounded to whole numbers per card. A double on an exact group match doubles
the card's points; a missed double scores nothing against the group. E.g. you
said S, others S A D B (21,875 each): 21,875 + 10,938 + 0 + 3,646 = 36,458;
group (S S A B D) median A: +18,750 → 55,208, or 36,458 doubled. Scores never
drop below zero.
Players are identified by three arcade-style initials: A–Z or _
(isValidInitials in packages/game).
After the group reveal comes the scoreboard: your total already includes
the round; everyone else counts up from their old total, then the order
reshuffles. Scores never drop below zero (applyPoints, floored point by
point). Round ends stay quick: the only action is Next round.
After the last scoreboard comes the bonus (awardBonus in
packages/game): a pool of (1,000,000 − the game's best round score) × rounds
× players, handed out as superlatives. Mind reader (most exact group matches),
Living on the edge (most S/F calls the group agreed with), Sure thing (most
doubles that paid off), Soulmates (the pair who agreed on the most cards; 3+
players), Round of the game (best single round) and Strong finish (best last
round). The pool is split evenly between the awards somebody won, and each
award between everyone tied for it. The awards land one at a time, then
everyone's total counts up with them.
After the bonus, game over shows a podium, the remaining standings,
and a swipeable recap of every round's group list. Each recap card is the
share image itself (rendered on-device; shared via the native share sheet or
downloaded, nothing is hosted). Then Rematch: the first signed-in player
to tap it hosts a new lobby with the same number of rounds (and the bot, if
there was one); everyone else gets a one-tap Join the rematch, and a
spectator screen follows the table to the new code (#rematch; the old game's
view carries rematch until that lobby starts).
Wikipedia categories (apps/server) #
generateCategories(gameId, api) walks down from curated root categories
(roots.ts), preferring bigger subcategories and skipping junk and sensitive
ones, until it has 10 categories that each have at least 21 vetted articles
(image, intro, and 1,000+ views in 30 days). Each game samples 7 items from a
category's 40 most-viewed vetted articles, so knowing the category doesn't
tell you the cards. Seeded by game id: the same id gives the same result for
the same Wikipedia data.
Wikipedia allows 200 requests/minute only if the User-Agent includes contact
info (otherwise 10/minute), so set WIKI_USER_AGENT:
WIKI_USER_AGENT="TierMadness/0.1 (https://example.org; you@example.org)" \
pnpm --filter @tier-madness/server generate my-game-id
Protocol (packages/protocol) #
Lexicons live in lexicons/games/cavu/tier-madness/; regenerate types with
pnpm --filter @tier-madness/protocol codegen.
Every secret move (vote, placement, swap, double) is a move record with a
random nonce, written to a secret space hosted by the game server, one per
round ({game}-r{round}). Its CID goes in a commitment record (same rkey) in
the game's commitment space, which the players can read. The server reads
each round's secret space once everyone has committed, checks every move
against its commitment (verifyMove), and reveals; the round's secret space
then opens to the players so anyone can check. The category vote (round 0)
opens at game end. CIDs match what a PDS computes (tested against
@atproto/common).
Records are meant to replay a whole game on their own, and every record is
written by a player to their own PDS (the game server only hosts the spaces).
The host writes the game record (players in seat order, ballot). At each
round's reveal, every player writes a round record (category, criteria and
who picked it, full cards in deal order); vote moves carry the full ballot and
placements carry the full card. Replay checks that everyone's records agree.
Players join with a 4-character code (Crockford base32: no I/L/O/U, ~1M
codes) and no join record: the host writes a public lobby record announcing
the game, carrying only an HMAC of the code under the game server's secret (an
unkeyed hash of ~1M codes is brute-forced in a second). The server indexes
lobby records by that hash (so it can rebuild after a restart), hashes the
code sent in #join to look it up, and limits guesses per account and IP. The host's
game record fixes the roster when the game starts.
Ratings use the garden.lexicon.dazzling-boarfish.rating shapes, embedded in
our records rather than written as separate records: the game record holds
the tier list as an ordinal scale (the tier values: S 6, A 3, B 1, C −1, D −3, F −6), each round holds its
criteria as a prompt, and each placement is a rating (subject = the card's
source URI, value = an ordinalValue on the scale). Those lexicons are
vendored in lexicons/garden/… with two spec fixes the published versions
need to pass atproto's validator: blob-shaped inline objects → blob, and
maxItems → maxLength.
The game socket (subscribeGame) uses atproto event-stream framing (DAG-CBOR
header + body) in both directions: clients send clientFrame intents, the
server sends per-viewer #snapshots. Clients authenticate with a service-auth
JWT from their own PDS.
Space host (apps/server/src/spaces) #
The game server hosts every game's spaces (atproto proposal 0016, October 2026 alpha wire format, as vivarium implements it) but stores no records; players write to their own PDSes.
- Identity:
did:webofPUBLIC_URL, served at/.well-known/did.json, with one secp256k1 key (#atproto) and#atproto_pds/#atproto_space_hostpointing back at the server.pnpm --filter @tier-madness/server keygenmakesSPACE_HOST_KEY. getSpaceCredential: verifies the player's delegation token (from their PDS, single use) and their HTTP message signature over it (RFC 9421,atproto-space, P-256), asks the game'sSpacePolicywhether they may read, and issues a 10-minute credential bound to that key (cnf.kid). Credentials are used asAuthorization: Atproto-Space …with anatproto-space-audienceheader, both signed by the bound key.notifyWrite: verifies the writer's service auth, sequences roster members' writes by space revision (stalerepoRevs ignored), and tells the game.listRepospages through them.- The server reads players' records (
createSpaceReader) under credentials it issues itself.
Token and signature code is vendored (tokens.ts, http-signature.ts)
because the published @atproto/space@alpha can't be installed.
Testing on a local atmosphere #
pnpm --filter @tier-madness/server test:integration spawns
vivarium and runs the
host against its real PDS: players write moves and commitments into our
spaces, vivarium notifies us, we read and verify their moves, and players
read each other's records under our credentials (CIDs we commit to match the
CIDs the PDS reports). To run the server against a vivarium by hand:
npx @vivarium-dev/cli # http://localhost:2580
PORT=3000 PLC_URL=http://localhost:2580 LOCAL_ATMOSPHERE=1 pnpm --filter @tier-madness/server start
(LOCAL_ATMOSPHERE=1 allows plain-http identity lookups; production keeps
the SSRF-protected default.)
Game socket and spectating #
The game server serves games.cavu.tier-madness.subscribeGame on the same
port as the space host (/xrpc/games.cavu.tier-madness.subscribeGame),
atproto event-stream framing both ways. Frames on one socket are handled in
order.
- Players:
#auth(a service-auth token from their own PDS,aud= the game server's DID) or the session cookie, then#create(host; replies#createdwith the join code and thecodeHashfor the host's lobby record) or#join {code}. The host#starts; the lobby then closes to new players. - Guests: "Play as a guest" (
POST /api/guest) puts a server-minteddid:guest:…id in the session cookie instead of signing in. Guests join and play like anyone else, but have no PDS, so nothing of theirs is recorded (their moves, commitments androundrecords are skipped; their moves exist only in the server's memory). They can't#create: the host writes thelobbyrecord carrying the join code. Hosting that passes on goes to a signed-in player first, and to a guest only if no one else is left. - Spectators: just
#spectate {code}, no sign-in; they see only what anyone may. Codes stay valid for spectating after the game starts. - Wrong codes are rate limited per account and per IP.
- Every change sends each socket a
#snapshotof the game as it may see it.
A join link (/?join=K7Q2, the lobby's QR code) goes straight into that
lobby: through sign-in or "Play as a guest" if needed, and asking only for
initials if none are saved.
The web app's spectator screen is at /spectate (or /spectate?code=K7Q2):
enter the code, see who's joined (host and connection status), and show the
code big for a TV, tap to hide. It reconnects on its own.
To see it locally:
pnpm --filter @tier-madness/server exec vivarium serve --no-upstream --port 2580
PORT=3000 PLC_URL=http://localhost:2580 LOCAL_ATMOSPHERE=1 pnpm --filter @tier-madness/server start
pnpm dev # web on :5173
pnpm --filter @tier-madness/server demo-lobby --players 5 # bots fill a lobby, prints the code
Round engine (apps/server/src/game) #
The server runs each game in memory and is authoritative. Players sign in through the server (server-side OAuth, not built yet) and the server writes into their PDSes on their behalf, so every intent goes:
- validate (phase, turn, deal order, slot caps, swap/double rules),
- record in the player's own PDS: the secret
movein the round's secret space and itscommitment(same rkey, CID checked) in the commitment space, - then apply. A failed write (
WriteFailed) leaves the game unchanged, so the records always cover the whole game.
Phases: vote (secret; tallied into the lineup with tallyVotes) → per
round pickCriteria (the round's picker; not secret) → deal (each player at
their own pace: deal → swap → double → waiting, the next card dealt only after
the last placement is recorded) → reveal (the round is checked with
checkRound, scored with scoreRound, its secret space opens to the players,
and everyone's round record is written) → host #advances through each card
→ final (the group's list) → scores (banked) → next round, or gameover
(the vote space opens). The host's lobby record is written on create and the
game record on start.
The ballot comes from the cached category pool (seedGame). Until OAuth
sign-in exists, writing to players' PDSes works against a local vivarium only
(VIVARIUM_URL, using its dev sessions). test/game.integration.test.ts plays
a full game that way and checks every secret move against its commitment.
Jev bots (apps/server/src/bots) #
The host can seat one bot per game from the lobby (#addBot; remove it with
#kick), so one person can play against it. It's played by
Jev, TypeSafe's System One model. Code owns the
turn; Jev answers narrow questions:
- Placing: a Score question, with the scoring rules in its state, asks
which tier most of the table will put each dealt card in. Its probabilities
over the six tiers are the bot's forecast, and code plays the tier with the
most expected points (
expectedPoints). S and F pay double B and C, so an outlier goes there when it's likely enough: 40% S / 60% A plays S (87,500 expected vs A's 75,000). - Voting / picking criteria: Choice questions.
- Swap and double: also by expected points. It swaps the pair worth the most (where a later, surer S or F takes the slot), and doubles the card whose expected bonus most outweighs the group points a miss gives up, if any does.
Bots are did:bot:… seats with no PDS: like guests, nothing of theirs is
recorded. They never host, and they don't start the vote or deal countdowns
on everyone else. If Jev can't be reached, a bot plays a random legal move
rather than stall the game.
Bots need TYPESAFE_API_KEY (from the TypeSafe console);
without it, the server refuses #addBot.