Log your board game plays on ATproto. rolld.at
atproto games
TypeScript 93%
CSS 5%
2%
HTML <1%
Shell <1%
JavaScript <1%

README.md

rolld #

A board game tracker built on the AT Protocol. Log the games you play, rate them, write up how a session went — all stored as records on your own atproto account, not in a database someone else keeps. The social reads ("who's played this", "how many times") come from the public Constellation backlink index, so there's no plays database and almost no server to run.

🌐 rolld.at

"rolld" reads as "roll'd" — rolling dice, the heart of tabletop gaming — and the domain rolld.at puns on "roll'd at", ending, fittingly, in atproto's ".at". The mark is a pixel die 😃

This README is for anyone who wants to run their own copy. If you just want to use rolld, head to rolld.at — you don't need any of this.

The idea #

rolld runs on the same idea as my other atproto projects: your data lives on your PDS, the reads come from Constellation, and there's mostly no server to run.

  • Your data is yours. Each play is an at.rolld.play record and each grade an at.rolld.rating record, written to your repo with browser OAuth. If the app you logged them in disappeared, your log would still be there, still readable by any other app on the network.
  • No plays database. Every play carries a canonical per-game URL, and "times played", "who played this", and friend filters are answered by querying Constellation for backlinks to that URL. There's no AppView indexer to run.
  • Game details are hydrated, not stored. Names, covers, and weights come from BoardGameGeek at display time, through a small cache keyed by the BGG id (the one permanent identity key). The source sits behind a resolveGame() seam, so it's swappable.
  • Social when your people arrive. Plays are public records, so friends on the network can follow along, and you can optionally share a play or rating as a companion post.

Architecture at a glance #

   Static SPA (Cloudflare Pages, vanilla TS + @atcute/* OAuth)
     ├─ log / rate UI + your social dashboard
     ├─ OAuth ── createRecord ──▶ YOUR PDS   (play, rating, profile; each play + rating
     │                                         carries a canonical rolld.at/g/<bggId> target)
     ├─ "my plays / my ratings"   ── read your own repo
     ├─ per-game social/aggregate ── Constellation backlinks to game targets
     ├─ friends-feed              ── Slingshot (identity + records) + Spacedust (live tail)
     ├─ game metadata + search    ── Cloudflare Worker (KV cache + BGG token) ──▶ BGG XML API
     └─ optional share            ── feed post ──▶ links to the entry permalink

The main server-side piece is a stateless Cloudflare Worker (worker/). It's the sole gateway to BoardGameGeek — it holds the BGG token as a secret and injects it server-side, so the browser never calls BGG. It also serves per-user RSS feeds and enforces the moderation blocklist.

The other one is a small Pages middleware (functions/) that fills in the <head> of each page as it's served: at: meta tags pointing back at the record the page renders, so any tool can open it with whatever app it likes, plus a real title and description instead of the same site-wide one everywhere. Everything else runs in the browser.

Lexicons (at.rolld.*) #

NSID Key Purpose
at.rolld.play tid One record per play session. Carries the canonical game target URL, optional players/photos/Markdown note, and an optional companion post ref.
at.rolld.rating BGG id Your 1–10 grade for a game, overwritable in place. Same note model, same target.
at.rolld.profile self A one-line declaration that an account uses rolld — what the friends-feed filters on.
at.rolld.defs — Shared gameRef (BGG id + name snapshot), player, and image.

Game metadata is never stored in a record — only the BGG id and a name snapshot, so a record stays meaningful even if BoardGameGeek vanishes. The full data model, and the reasoning behind every odd-looking choice, lives in DECISIONS.md.

Running your own copy #

You'll need a couple of things of your own first:

  • A BGG application token. BGG requires a registered, server-side token for the XML API (register a non-commercial app — it's generally free, but approval can take a week or more). This token is yours; it never goes near the browser.
  • A Cloudflare account, for the Pages site and the Worker.
  • A domain, since atproto OAuth won't accept localhost and the OAuth client metadata is served at a conventional path on your own domain.

The SPA #

npm install
npm run dev          # atproto OAuth forbids localhost; dev uses the loopback IP
npm test             # vitest
npm run typecheck    # tsc (app + node scripts + worker)
npm run lint
npm run build        # typecheck + production build into dist/

The BGG cache Worker #

The Worker lives in worker/ and runs separately:

npm run worker:dev       # wrangler dev (local KV)
npm run worker:deploy    # wrangler deploy

# One-time setup for your own instance:
wrangler kv namespace create BGG_CACHE   # then put the id in worker/wrangler.toml
wrangler secret put BGG_TOKEN            # your own BGG application token

Point the SPA at your local Worker with VITE_API_BASE while developing.

The lexicons #

The schemas have their own pre-publish gate:

npm run check:lexicons    # the validator + goat lex parse/lint

You only need this if you're publishing the at.rolld.* schemas to the network under your own authority. To run rolld as-is, you don't have to.

Deploying to Cloudflare Pages #

rolld's own git remote is on tangled.org, so Cloudflare's git-connected builds aren't wired up. The deploy builds locally and pushes dist/ with Wrangler direct upload:

npm run deploy       # build + wrangler pages deploy --branch=trunk

The project name, the output directory and the Function bindings all come from the root wrangler.toml, which Pages treats as the source of truth once it's there — those fields can't be edited in the dashboard any more. The --branch flag still matters: only deployments matching your project's production branch get promoted to the live site.

The binding in that file is load-bearing. The middleware has to ask the Worker about moderation and game names, and a Pages Function cannot reach a Worker route on its own zone over fetch — same-zone Worker-to-Worker calls need a service binding (Cloudflare error 1042). Without it the calls fail, and since the moderation gate fails closed, entry and game pages quietly drop back to the generic head.

Wrangler picks up functions/ from the repo root on its own, so the middleware ships with the same command. Plain npm run dev serves the Vite SPA without it, so the served <head> is the static one and only the client-side half runs. To exercise the middleware for real, build against a local Worker and point Pages at it:

VITE_API_BASE=http://127.0.0.1:8787 npm run build
npm run worker:dev                                    # in another terminal, port 8787
npx wrangler pages dev dist --port 12530 \
  --compatibility-date=2026-07-07 \
  --binding API_ORIGIN=http://127.0.0.1:8787

Port 12530 because the Worker's CORS allowlist knows it. API_ORIGIN points the middleware at the local Worker; in production the service binding handles that instead. And the compatibility date has to be one your installed workerd supports — the default is today's date, which it usually isn't yet.

License #

Dual-licensed under MIT OR Apache-2.0, at your option — mirroring the AT Protocol ecosystem, and because I want the lexicons reused without anyone having to ask. See LICENSE-MIT and LICENSE-APACHE.

Third-party data, schema, and font attributions (including BoardGameGeek) are in NOTICE.md.