AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 182 lines 11 kB View raw View rendered
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.