Tier Ranking Game

README.md

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:web of PUBLIC_URL, served at /.well-known/did.json, with one secp256k1 key (#atproto) and #atproto_pds / #atproto_space_host pointing back at the server. pnpm --filter @tier-madness/server keygen makes SPACE_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's SpacePolicy whether they may read, and issues a 10-minute credential bound to that key (cnf.kid). Credentials are used as Authorization: Atproto-Space … with an atproto-space-audience header, both signed by the bound key.
  • notifyWrite: verifies the writer's service auth, sequences roster members' writes by space revision (stale repoRevs ignored), and tells the game. listRepos pages 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 #created with the join code and the codeHash for 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-minted did: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 and round records are skipped; their moves exist only in the server's memory). They can't #create: the host writes the lobby record 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 #snapshot of 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:

  1. validate (phase, turn, deal order, slot caps, swap/double rules),
  2. record in the player's own PDS: the secret move in the round's secret space and its commitment (same rkey, CID checked) in the commitment space,
  3. 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.