word-waltz / files / AGENTS.md
AGENTS.md
f755bca6 2026-03-23 Pavel Safronov | 1 | # Word Waltz — Agent Instructions |
| 2 | ||
| 3 | This file contains instructions and context for AI coding agents (GitHub Copilot, etc.) working in this repository. | |
| 4 | ||
| 5 | ## Project overview | |
| 6 | ||
| 7 | Word Waltz is a two-player asynchronous Wordle duel web app. Players alternate challenging each other with secret 5-letter words and scoring based on how many guesses it takes to solve. Games run indefinitely with a running scoreboard. | |
| 8 | ||
| 9 | ## Tech stack (non-negotiable) | |
| 10 | ||
| 11 | - **Build**: Vite — all source under `src/`, output to `dist/` | |
| 12 | - **Hosting**: GitLab Pages (CI pipeline in `.gitlab-ci.yml`) | |
| 13 | - **Storage**: Firebase Firestore | |
| 14 | - **Encryption**: Web Crypto API, AES-GCM 256-bit, key embedded in URL fragment | |
| 15 | - **Language**: TypeScript (strict mode, ES modules, no framework) | |
| 16 | - **QR codes**: `qrcode` npm package | |
| 17 | - **Styling**: Plain CSS (no CSS framework unless the user explicitly approves one) | |
| 18 | ||
| 19 | ## Repository structure | |
| 20 | ||
| 21 | ```text | |
| 22 | src/ | |
| 23 | index.html # Single-page app shell | |
| 24 | main.ts # Entry point: parses URL, bootstraps the right view | |
| 25 | types.ts # Shared TypeScript types (GameState, Player, Round, etc.) | |
| 26 | styles/main.css # Global styles | |
| 27 | components/ | |
| 28 | WordPicker.ts # Word selection UI (challenge creation) | |
| 29 | WordleBoard.ts # 6×5 guess grid rendering and interaction | |
| 30 | Keyboard.ts # On-screen keyboard; colors keys as letters are used | |
| 31 | Scoreboard.ts # Displays cumulative scores for both players | |
| 32 | ShareDialog.ts # Shows shareable URL and renders a QR code | |
| 33 | firebase/ | |
| 34 | config.ts # Firebase SDK init — never commit real credentials | |
| 35 | gameService.ts # All Firestore operations (read/write game documents) | |
| 36 | crypto/ | |
| 37 | encryption.ts # encrypt(plaintext, key) / decrypt(ciphertext, key) | |
| 38 | utils/ | |
| 39 | wordList.ts # Word list and isValidWord(word) helper | |
| 40 | scoring.ts # calculateScore(guessCount, solved) → number | |
| 41 | shareLink.ts # buildLink(gameId, key) / parseLink(url) → {gameId, key} | |
| 42 | ``` | |
| 43 | ||
| 44 | ## Game rules (implement faithfully) | |
| 45 | ||
| 46 | - Words must be exactly 5 letters, from the valid word list. | |
| 47 | - The guess grid is 6 rows × 5 columns. | |
| 48 | - Tile states after a guess: | |
| 49 | - **Green** (`correct`): right letter, right position. | |
| 50 | - **Yellow** (`present`): right letter, wrong position. | |
| 51 | - **Gray** (`absent`): letter not in the word at all. | |
| 52 | - The on-screen keyboard mirrors the best-known state for each letter. | |
| 53 | - A round ends when the solver either guesses correctly or exhausts all 6 attempts. | |
| 54 | - Score = `max(0, 7 - guessCount)` (perfect = 6, failed = 0). | |
| 55 | - After a round ends, the solver becomes the challenger and picks a new word. | |
| 56 | - Both players' cumulative scores are always visible. | |
| 57 | ||
| 58 | ## Firestore document shape | |
| 59 | ||
| 60 | Game data is stored in two layers. The Firestore document contains only the | |
| 61 | encryption envelope plus one intentionally-plaintext field used for cleanup: | |
| 62 | ||
| 63 | ```js | |
| 64 | // What is stored in Firestore (top-level fields) | |
| 65 | { | |
| 66 | iv: string, // base64 — AES-GCM initialisation vector | |
| 67 | ciphertext: string, // base64 — encrypted JSON blob | |
| 68 | lastPlayed: number, // Unix ms — UNENCRYPTED; the only plaintext field; used for automated cleanup | |
| 69 | } | |
| 70 | ``` | |
| 71 | ||
| 72 | The decrypted `ciphertext` contains the full game state as JSON: | |
| 73 | ||
| 74 | ```js | |
| 75 | // Decrypted game state (JSON inside ciphertext) | |
| 76 | { | |
| 77 | players: { | |
| 78 | a: { name: string, score: number }, | |
| 79 | b: { name: string, score: number }, | |
| 80 | }, | |
| 81 | rounds: [ | |
| 82 | { | |
| 83 | challenger: 'a' | 'b', | |
| 84 | word: string, // the secret word | |
| 85 | guesses: string[], // ordered list of guesses made | |
| 86 | solved: boolean, | |
| 87 | score: number, | |
| 88 | }, | |
| 89 | // ... | |
| 90 | ], | |
| 91 | currentRound: number, // index into rounds[] | |
| 92 | turn: 'pick' | 'solve', // what the non-challenger should do next | |
| 93 | createdAt: number, // Unix ms | |
| 94 | updatedAt: number, | |
| 95 | } | |
| 96 | ``` | |
| 97 | ||
| 98 | `lastPlayed` is updated on every write alongside the ciphertext. It reveals | |
| 99 | only that the game was active at that time — no game content is exposed. | |
| 100 | The encryption key is only in the URL fragment. | |
| 101 | ||
| 102 | ## Encryption model | |
| 103 | ||
| 104 | - Key generation: `crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])` | |
| 105 | - Export key as `raw`, encode as URL-safe base64 for the URL fragment. | |
| 106 | - IV: random 12-byte value prepended to every ciphertext blob. | |
| 107 | - Firestore document fields: `{ iv: base64, ciphertext: base64, lastPlayed: unixMs }` | |
| 108 | - Functions live in `src/crypto/encryption.js` and must be the sole place encryption/decryption happens. | |
| 109 | ||
| 110 | ## URL format | |
| 111 | ||
| 112 | ```text | |
| 113 | https://host/game/#gameId=<id>&key=<base64url-key> | |
| 114 | ``` | |
| 115 | ||
| 116 | - Fragment is never sent over the network by the browser. | |
| 117 | - `shareLink.js` owns all construction and parsing of this format. | |
| 118 | - Never put the key in the path or query string. | |
| 119 | ||
| 120 | ## Coding conventions | |
| 121 | ||
| 122 | - ES modules throughout (`import`/`export`); no CommonJS. | |
| 123 | - No build-time secrets. Firebase config is committed as a placeholder; real values are injected via environment variables in CI using Vite's `import.meta.env` pattern, prefixed with `VITE_`. | |
| 124 | - Components are plain functions that accept a DOM container and return a cleanup function, e.g.: | |
| 125 | ||
| 126 | ```ts | |
| 127 | export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps): () => void { | |
| 128 | // ... render into container | |
| 129 | return function cleanup() { /* teardown */ }; | |
| 130 | } | |
| 131 | ``` | |
| 132 | ||
| 133 | - No global mutable state outside of `main.ts`. Pass state down as arguments. | |
| 134 | - All async Firebase and Crypto operations must handle errors and surface them to the user. | |
| 135 | - CSS class names follow BEM: `block__element--modifier`. | |
| 136 | ||
| 137 | ## Things to avoid | |
| 138 | ||
| 139 | - Do not introduce a JS framework (React, Vue, Svelte, etc.) without explicit user approval. | |
| 140 | - Do not add npm packages without explaining why and getting approval. | |
| 141 | - Do not store the encryption key in `localStorage`, `sessionStorage`, Firestore, or any server-visible location. | |
| 142 | - Do not commit real Firebase credentials. Use `VITE_*` environment variables. | |
| 143 | - Do not skip input validation — all words submitted by users must be checked against the word list. | |
| 144 | ||
| 145 | ## Firestore security rules (guidance) | |
| 146 | ||
| 147 | Since confidentiality is enforced by client-side encryption, Firestore rules can be relatively open. A reasonable baseline: | |
| 148 | ||
| 149 | ```text | |
| 150 | rules_version = '2'; | |
| 151 | service cloud.firestore { | |
| 152 | match /databases/{database}/documents { | |
| 153 | match /games/{gameId} { | |
| 154 | allow read, write: if true; | |
| 155 | } | |
| 156 | } | |
| 157 | } | |
| 158 | ``` | |
| 159 | ||
| 160 | For stricter rules (optional): require the `gameId` to match a specific format (UUID), or require at least one read before a write. | |
| 161 | ||
| 162 | ## GitLab Pages deployment | |
| 163 | ||
| 164 | - CI file: `.gitlab-ci.yml` | |
| 165 | - Build command: `npm run build` | |
| 166 | - Pages artifact: `dist/` | |
| 167 | - Environment variables for Firebase config are set as GitLab CI/CD variables (masked), injected into Vite via `VITE_*` prefix. |