AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
1# AT Mot
2
3**A bilingual (English / French) daily word game, native to the [AT Protocol](https://atproto.com).**
4Guess the hidden five-letter word in six tries. Your results live in **your own PDS**, and the
5leaderboard is read from the public **[Constellation](https://constellation.microcosm.blue)**
6backlink index.
7
8🌐 **[atmot.herve.bzh](https://atmot.herve.bzh)**
9
10> _“mot” is French for “word” — and a play on at**proto**._
11
12---
13
14AT Mot uses a **”data on your PDS, reads via Constellation, no AppView”** architecture for atproto
15apps:
16
17- **Your data is yours.** Each finished puzzle is written as a `bzh.herve.atmot.result` record to
18 your repo via browser OAuth. There is no server storing your games — there is no server.
19- **The leaderboard has no database.** Every result record links to a canonical per-day permalink.
20 The daily stats and shared grids are built by querying Constellation for backlinks to that
21 permalink and aggregating **in the browser**.
22- **Deterministic & global.** Each language is its own daily series; the day's word is computed on
23 every client from a bundled list, indexed by whole **UTC** days since launch. Everyone worldwide
24 gets the same word on the same calendar day, with no server in the loop.
25- **Standards-conformant lexicons** under the `bzh.herve.atmot.*` authority, following the
26 [Lexicon Style Guide](https://atproto.com/guides/lexicon-style-guide).
27- **Installable to your home screen.** A web manifest makes it installable as a standalone app.
28
29## Architecture at a glance
30
31```
32 ┌─────────────── browser (static SPA on Cloudflare Pages) ───────────────┐
33 │ │
34 play │ deterministic daily word (bundled list, UTC day index) │
35 │ │ │
36 write │ OAuth session ── createRecord ──▶ YOUR PDS │
37 │ result (write-once) ├─ bzh.herve.atmot.result │
38 │ stats (self, mutable) └─ bzh.herve.atmot.stats │
39 │ │
40 share │ createRecord ──▶ app.bsky.feed.post (emoji grid + puzzle permalink) │
41 │ │
42 read │ Constellation backlinks to the permalink ──▶ leaderboard + shared │
43 │ (counts + record lists) grids + live likes/reposts/replies │
44 └─────────────────────────────────────────────────────────────────────────┘
45```
46
47The only permitted server-side component is an **optional, stateless** Cloudflare Worker cache
48(documented fallback, not used by default). Core gameplay and reads work entirely client-side.
49
50## Lexicons
51
52Published as `com.atproto.lexicon.schema` records under **`bzh.herve.atmot.*`**:
53
54| NSID | Key | Purpose |
55| --- | --- | --- |
56| `bzh.herve.atmot.result` | `any` (`<lang>-<n>`) | One **immutable, write-once** record per completed puzzle. Stores tile **colours only** — never the answer or guessed letters. Carries the `puzzleTarget` permalink that Constellation indexes. |
57| `bzh.herve.atmot.stats` | `self` | A single **mutable** streak/stats record. Also the app's **declaration record**: its presence means "this account plays AT Mot"; its deletion means opt-out. |
58| `bzh.herve.atmot.defs` | — | Shared client-side view objects (`resultView`, `leaderboardEntry`, `dailyStats`). |
59
60The `puzzleTarget` is a canonical web URL — `https://atmot.herve.bzh/p/<lang>/<puzzleNumber>` —
61**emitted from a single shared helper** so the write path and the Constellation read path can never
62diverge. The format is frozen (Constellation compares it literally).
63
64Validate the schemas against the Style Guide rules:
65
66```sh
67npm run lexicons:validate
68```
69
70Publish them (run by the account that controls the namespace authority):
71
72```sh
73ATMOT_PUBLISH_IDENTIFIER=you.example ATMOT_PUBLISH_PASSWORD=<app-password> npm run lexicons:publish
74```
75
76> For network-wide lexicon resolution, the authority domain also needs a `_lexicon` DNS TXT record
77> (`did=<publishing did>`) pointing at the publishing account's DID.
78
79## Word lists
80
81Self-curated per language, from permissively-licensed sources, normalized to **uppercase,
82unaccented A–Z** at build time (French diacritics are stripped and ligatures expanded — `Œ→OE`,
83`Æ→AE` — matching the accent-free convention of the official French Scrabble dictionary):
84
85| Language | Dictionary | Commonness ranking |
86| --- | --- | --- |
87| English | [ENABLE](https://github.com/dolph/dictionary) — **Public Domain** | hermitdave/FrequencyWords (MIT) |
88| French | [an-array-of-french-words](https://github.com/words/an-array-of-french-words) — **MIT** | hermitdave/FrequencyWords (MIT) |
89
90Lists are generated by `npm run build:words` and committed (the generated JSON _is_ the game
91content); the build is reproducible (same sources + fixed seeds → identical output).
92
93## Develop
94
95```sh
96npm install
97npm run dev # http://127.0.0.1:12520 (atproto OAuth forbids localhost; use the loopback IP)
98npm test # vitest — deterministic engine, scoring, stats, share, lexicon format
99npm run typecheck # tsc (app + node scripts)
100npm run lint
101npm run build # typecheck + production build into dist/
102```
103
104OAuth in dev uses atproto's loopback client (no hosted metadata needed). In production the SPA serves
105`/client-metadata.json`, and `client_id` equals that exact URL.
106
107AT Mot requests **granular OAuth scopes** (least privilege) rather than the broad `transition:generic`
108— write access is limited to exactly the three collections it creates: `bzh.herve.atmot.result`,
109`bzh.herve.atmot.stats`, and `app.bsky.feed.post`. See DECISIONS.md (#17b).
110
111## Deploy (Cloudflare Pages)
112
113The git remote lives on [tangled.org](https://tangled.org), so Cloudflare's git-connected builds
114aren't available. Deployment uses **Wrangler direct upload** instead — build locally, then push the
115`dist/` folder straight to the `atmot` Pages project:
116
117```bash
118npm run deploy
119```
120
121This runs `npm run build` (typecheck + Vite build) and then
122`wrangler pages deploy dist --project-name=atmot --branch=trunk`. The first run prompts for
123`wrangler login`.
124
125The `--branch=trunk` flag is important: Wrangler tags a deployment with your current local git
126branch, and Cloudflare only promotes deployments whose branch matches the project's **production
127branch** (`trunk`). Without it, deploying from a feature branch produces a preview URL and leaves the
128live site unchanged.
129
130- **Output directory:** `dist`
131- **Pages project:** `atmot`
132- **Production branch:** `trunk`
133- **Custom domain:** `atmot.herve.bzh`
134
135`public/_redirects` provides the SPA history fallback so the `/p/<lang>/<n>` permalinks resolve to
136real pages. No origin server or environment variables are required for core gameplay.
137
138## License
139
140Dual-licensed under **MIT** OR **Apache-2.0** (mirroring atproto), at your option. See
141[LICENSE-MIT](./LICENSE-MIT) and [LICENSE-APACHE](./LICENSE-APACHE). Third-party data and font
142attributions are in [NOTICE.md](./NOTICE.md). Design rationale and judgment calls are recorded in
143[DECISIONS.md](./DECISIONS.md).