AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
1# Decisions
2
3Judgment calls made during the build, beyond what the spec already settled. Every
4hard-to-change-after-launch value (epoch, domain, `puzzleTarget` format, namespace, word-list rules)
5follows the spec exactly; the items here are the choices the spec left open or where a small,
6documented interpretation was needed.
7
8## Architecture & data
9
101. **Result record key is `"any"`, not `"tid"`.** The spec asked for `tid` semantics *and* a
11 deterministic rkey `<lang>-<puzzleNumber>` (e.g. `en-412`). A `tid` key forces TID-format rkeys,
12 which would reject `en-412`. The deterministic rkey is the load-bearing requirement (one record
13 per puzzle per account; parseable), so the lexicon uses `"key": "any"` and documents the
14 `<lang>-<puzzleNumber>` format in the record description. This is the standards-correct way to
15 express the intended constraint.
16
172. **Result records are written with `createRecord` (write-once).** A fixed rkey + `createRecord`
18 means re-finishing a puzzle is a no-op (the "already exists" error is treated as success), which
19 enforces immutability and prevents stats double-counting at the protocol layer.
20
213. **`dailyStats.averageGuesses` is an integer ×100** (e.g. `412` = 4.12). Lexicons avoid floats;
22 scaling keeps it unambiguous.
23
24## Word lists
25
264. **Sources:** English dictionary = **ENABLE** (Public Domain); French dictionary =
27 **an-array-of-french-words** (MIT); commonness ranking for both = **hermitdave/FrequencyWords**
28 (MIT, OpenSubtitles).
29
305. **Validity vs. commonness are separated.** Frequency lists alone leak proper nouns, foreign
31 words, and apostrophe-mangled contraction fragments. So the *dictionary* decides validity and the
32 *frequency* list only decides commonness/ordering. French additionally drops dictionary entries
33 containing apostrophes/hyphens and a small set of elision stems (`JUSQU`, `LORSQU`, …).
34 English additionally keeps obvious **inflected `-S` forms** (regular plurals, `-es`/`-ies`
35 plurals, and 3rd-person verbs like `ROOMS`, `BOXES`, `FLIES`) **out of the answer pool** — they
36 stay valid as guesses. Detection (`scripts/plurals.ts`) checks the singular against the *full*
37 ENABLE dictionary of every length; an earlier version checked only the 5-letter subset, so no
38 4-letter singular ever matched and the filter silently did nothing.
39
406. **Answer order is a fixed seeded shuffle baked into the committed JSON.** Daily agreement comes
41 from shared *data* (every client reads the same ordered array) rather than runtime computation.
42 The per-language seeds are frozen; changing them would renumber history.
43
447. **Generated word JSON is committed.** It *is* the game content and must be byte-identical across
45 clients; `npm run build:words` regenerates it reproducibly.
46
47## Share & Constellation read path
48
498. **The permalink is posted as a real richtext link facet *and* an external embed**, not plain
50 text. Constellation only indexes URIs at link positions (`.facets[].features[…].uri`,
51 `.embed.external.uri`) — a plain-text URL would be invisible to it, breaking shared-post
52 discovery. Discovery queries both paths and dedupes.
53
549. **Engagement is reported as distinct DIDs** (people who liked/reposted/replied) via Constellation's
55 `count/distinct-dids` — one clean endpoint, and arguably more meaningful than raw counts. Replies
56 are counted at `.reply.parent.uri`.
57
5810. **Leaderboard aggregation samples up to 200 result records**, fetched with bounded concurrency
59 and aggregated in-browser; the UI flags when the sample is capped. Everything returns
60 null/empty on failure so the page degrades instead of crashing.
61
6211. **No Cloudflare Worker fallback was built.** The spec makes it optional ("only if needed");
63 core gameplay and reads work entirely client-side, verified against the live Constellation
64 instance.
65
66## Design
67
6812. **Type = Overused Grotesk (display/body) + IBM Plex Mono (data/provenance)**, self-hosted
69 (both OFL).
70
7113. **Board palette:** teal `#0F766E` (correct), gold `#A16207` (present), recessive slate (absent).
72 Deliberately distinct from the usual green/yellow word-game scheme. Two refinements came out of early player feedback:
73 the present colour was nudged from the original
74 burnt amber `#B45309` (which read as *red* — the classic red/green colour-blind trap) to a clearer
75 gold; and the absent tone is now **theme-aware and shared with the keyboard** (light: slate
76 `#5B6776`; dark: `#232B35`) so the "not in word" state looks identical on the board tile, the
77 keyboard key, and the legend. The dark absent stays readable as a tile yet recessive. Per-tile
78 aria labels announce the state regardless of colour. The shared emoji grid stays standard
79 `🟩🟨⬛`.
80
8113a. **Colour-blind mode is opt-in, off by default.** Rather than always drawing a corner glyph
82 (`✓` right spot / `○` wrong spot / `✕` not in word) — which read as visual clutter — the glyphs
83 appear only when the player enables "Colour-blind mode" from a footer toggle. The preference is
84 stored in `localStorage` and applied as a `cb-safe` class on `<html>`; the glyph CSS keys off
85 that class, so it lights up the board, the legend, and the help-modal examples at once. The
86 first-visit help modal points colour-blind players at the toggle (only while it's still off).
87
8813b. **Onboarding: a persistent legend + a first-visit help modal.** New players had no way to learn
89 what the colours mean (also from the same feedback). A compact colour/symbol legend sits under the
90 board during play; a `?` header button opens a native `<dialog>` "How to play" with worked
91 examples, shown automatically once on first visit (guarded by `localStorage`) and re-openable any
92 time. Original copy, bilingual, no login/email upsell.
93
9414. **Dark mode follows `prefers-color-scheme` only** (no toggle), via semantic CSS custom
95 properties, per spec.
96
9714a. **Keyboard keys: grey when untried, dark when ruled out.** The original
98 keyboard did the opposite in dark mode — untried keys were dark and a tried-absent key turned a
99 *lighter* slate. Untried keys are now a clear neutral grey (`#3A424F` in dark) and an absent key
100 drops to the recessive shared "not in word" tone (darker than untried), so eliminated letters
101 recede instead of standing out. Correct/present keys keep the board's teal/gold.
102
10315. **Vanilla TypeScript, no UI framework** — keeps the static bundle tiny (~94 KB gzip), matching
104 the "lightest fit for a client-side game" rationale behind choosing atcute.
105
106## Auth / flow
107
10816. **Playing does not require sign-in.** You can play immediately; on finishing, the result is
109 recorded to your PDS if you're signed in, otherwise the finished game persists locally and is
110 recorded the moment you sign in (the write-once result prevents double-counting). This is
111 friendlier than gating play behind OAuth while staying fully serverless.
112
11317. **`redirect_uri` is the site root** (`https://atmot.herve.bzh/`); the OAuth callback is detected
114 from the hash params on load. Dev uses atproto's loopback client via a Vite plugin (no hosted
115 metadata needed locally).
116
11717b. **Granular OAuth scopes — no `transition:generic`.** The client requests exactly the writes it
118 performs, nothing more:
119
120 ```
121 atproto
122 repo:bzh.herve.atmot.result?action=create
123 repo:bzh.herve.atmot.stats?action=create&action=update
124 repo:app.bsky.feed.post?action=create
125 ```
126
127 This is least-privilege (the app can never touch any other collection in your repo) and gives a
128 clearer consent screen than the broad `transition:generic`. Verified end-to-end against a
129 self-hosted reference PDS (v0.4.5006): PAR → consent (rendered as "Repository: Publish changes" +
130 "Bluesky") → token granted with the exact scope → `createRecord`/`putRecord` all succeed. `repo:`
131 alone authorizes the `com.atproto.repo.*` write calls; no separate `rpc:` scope is needed. Reads
132 (leaderboard, own records) need no scope at all. Trade-off: a PDS too old to parse granular scopes
133 would reject authorization outright — acceptable, since `transition:*` scopes are themselves slated
134 for deprecation and the reference implementation supports granular today.
135
136## Tooling
137
13818. **Dev tooling (Vite/Vitest) bumped to latest majors** to clear all `npm audit` advisories. The
139 advisories were dev-server-only (esbuild), never in shipped code.
140
14119. **atcute versions:** `@atcute/client@^5`, `@atcute/oauth-browser-client@^4`,
142 `@atcute/identity-resolver@^2` (newer than the spec's illustrative snippets; the API used is
143 equivalent).
144
145## PWA / installability
146
14720. **Manifest-only PWA — deliberately no service worker.** The app is installable
148 to the home screen via `public/manifest.webmanifest` + committed PNG icons
149 (rasterized once from `icon.svg`, flattened onto `#0e1116`) + apple/mobile
150 web-app meta tags. There is intentionally **no service worker and no caching
151 layer**: this is a daily game with dynamic reads (Constellation leaderboard,
152 PDS writes, OAuth), and an over-eager cache is exactly what would serve a
153 stale puzzle or stale bundle. Consequence: iOS add-to-home-screen and
154 Android manual "Add to Home Screen" both work from the manifest alone; the
155 proactive Android install banner (which needs a service worker) is forgone on
156 purpose. Loading speed is unchanged (CDN + ~94 KB bundle) with no new failure
157 modes. The regression guard `tests/pwa.test.ts` uses `node:*` imports, so
158 `"node"` was added to the shared `tsconfig.json` `types` allowlist — a
159 conscious trade-off: Node globals become visible to `src/` typechecking, but
160 `noEmit` + Vite bundling mean it can't affect runtime; narrow later by giving
161 `tests` its own node-typed project if that boundary is wanted.
162
163## Anti-cheat
164
16521. **The daily-answer sequence ships obfuscated, not in plaintext.** The ordered
166 `answers` array used to ship as plain English words, so anyone could open the
167 JSON (in the repo or the JS bundle / network tab), find their puzzle number,
168 and read today's — or any future day's — word. It now ships as `answersEnc`:
169 the ordered list joined, XOR'd against a fixed key, and base64-encoded
170 (`src/engine/obfuscate.ts`), decoded once at module load in `words.ts`.
171 This is a deliberate **speed bump, not encryption** — with no backend, the full
172 list must reach every browser to compute the word offline, and the XOR key
173 ships in the bundle, so a determined reader can still recover it. The goal is
174 only to defeat the trivial "read the list / grep for the word" cheat. The
175 encoding decodes back to **byte-identical** words, so the per-puzzle mapping is
176 unchanged — recorded results (keyed by `<lang>-<puzzleNumber>`) and in-progress
177 local games from before the change still match. `tests/words.test.ts` pins
178 known puzzle→word pairs as a history-safety guard. Two accepted limits: (a) the
179 `allowed` guess dictionary stays plaintext (it's the public valid-word set and
180 reveals no day mapping); (b) French answer words that carry accents remain
181 individually visible as keys in the plaintext `accents` display map — but not
182 their day-order, so the daily mapping stays hidden.