Word Waltz — Agent Instructions
This file contains instructions and context for AI coding agents (GitHub Copilot, etc.) working in this repository.
Project overview
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.
Tech stack (non-negotiable)
- Build: Vite — all source under
src/, output todist/ - Hosting: GitLab Pages (CI pipeline in
.gitlab-ci.yml) - Storage: Firebase Firestore
- Encryption: Web Crypto API, AES-GCM 256-bit, key embedded in URL fragment
- Language: TypeScript (strict mode, ES modules, no framework)
- QR codes:
qrcodenpm package - Styling: Plain CSS (no CSS framework unless the user explicitly approves one)
Repository structure
src/
index.html # Single-page app shell
main.ts # Entry point: parses URL, bootstraps the right view
types.ts # Shared TypeScript types (GameState, Player, Round, etc.)
styles/main.css # Global styles
components/
WordPicker.ts # Word selection UI (challenge creation)
WordleBoard.ts # 6×5 guess grid rendering and interaction
Keyboard.ts # On-screen keyboard; colors keys as letters are used
Scoreboard.ts # Displays cumulative scores for both players
ShareDialog.ts # Shows shareable URL and renders a QR code
firebase/
config.ts # Firebase SDK init — never commit real credentials
gameService.ts # All Firestore operations (read/write game documents)
crypto/
encryption.ts # encrypt(plaintext, key) / decrypt(ciphertext, key)
utils/
wordList.ts # Word list and isValidWord(word) helper
scoring.ts # calculateScore(guessCount, solved) → number
shareLink.ts # buildLink(gameId, key) / parseLink(url) → {gameId, key}
Game rules (implement faithfully)
- Words must be exactly 5 letters, from the valid word list.
- The guess grid is 6 rows × 5 columns.
- Tile states after a guess:
- Green (
correct): right letter, right position. - Yellow (
present): right letter, wrong position. - Gray (
absent): letter not in the word at all. - The on-screen keyboard mirrors the best-known state for each letter.
- A round ends when the solver either guesses correctly or exhausts all 6 attempts.
- Score =
max(0, 7 - guessCount)(perfect = 6, failed = 0). - After a round ends, the solver becomes the challenger and picks a new word.
- Both players' cumulative scores are always visible.
Firestore document shape
Game data is stored in two layers. The Firestore document contains only the encryption envelope plus one intentionally-plaintext field used for cleanup:
// What is stored in Firestore (top-level fields)
{
iv: string, // base64 — AES-GCM initialisation vector
ciphertext: string, // base64 — encrypted JSON blob
lastPlayed: number, // Unix ms — UNENCRYPTED; the only plaintext field; used for automated cleanup
}
The decrypted ciphertext contains the full game state as JSON:
// Decrypted game state (JSON inside ciphertext)
{
players: {
a: { name: string, score: number },
b: { name: string, score: number },
},
rounds: [
{
challenger: 'a' | 'b',
word: string, // the secret word
guesses: string[], // ordered list of guesses made
solved: boolean,
score: number,
},
// ...
],
currentRound: number, // index into rounds[]
turn: 'pick' | 'solve', // what the non-challenger should do next
createdAt: number, // Unix ms
updatedAt: number,
}
lastPlayed is updated on every write alongside the ciphertext. It reveals
only that the game was active at that time — no game content is exposed.
The encryption key is only in the URL fragment.
Encryption model
- Key generation:
crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']) - Export key as
raw, encode as URL-safe base64 for the URL fragment. - IV: random 12-byte value prepended to every ciphertext blob.
- Firestore document fields:
{ iv: base64, ciphertext: base64, lastPlayed: unixMs } - Functions live in
src/crypto/encryption.jsand must be the sole place encryption/decryption happens.
URL format
https://host/game/#gameId=<id>&key=<base64url-key>
- Fragment is never sent over the network by the browser.
shareLink.jsowns all construction and parsing of this format.- Never put the key in the path or query string.
Coding conventions
- ES modules throughout (
import/export); no CommonJS. - 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.envpattern, prefixed withVITE_. - Components are plain functions that accept a DOM container and return a cleanup function, e.g.:
ts
export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps): () => void {
// ... render into container
return function cleanup() { /* teardown */ };
}
- No global mutable state outside of
main.ts. Pass state down as arguments. - All async Firebase and Crypto operations must handle errors and surface them to the user.
- CSS class names follow BEM:
block__element--modifier.
Things to avoid
- Do not introduce a JS framework (React, Vue, Svelte, etc.) without explicit user approval.
- Do not add npm packages without explaining why and getting approval.
- Do not store the encryption key in
localStorage,sessionStorage, Firestore, or any server-visible location. - Do not commit real Firebase credentials. Use
VITE_*environment variables. - Do not skip input validation — all words submitted by users must be checked against the word list.
Firestore security rules (guidance)
Since confidentiality is enforced by client-side encryption, Firestore rules can be relatively open. A reasonable baseline:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /games/{gameId} {
allow read, write: if true;
}
}
}
For stricter rules (optional): require the gameId to match a specific format (UUID), or require at least one read before a write.
GitLab Pages deployment
- CI file:
.gitlab-ci.yml - Build command:
npm run build - Pages artifact:
dist/ - Environment variables for Firebase config are set as GitLab CI/CD variables (masked), injected into Vite via
VITE_*prefix.