···11+## Project Configuration
22+33+- **Language**: TypeScript
44+- **Package Manager**: npm
55+- **Add-ons**: tailwindcss, sveltekit-adapter
66+77+---
88+99+# checkmate.blue
1010+1111+Federated chess platform built on AT Protocol. Players authenticate with Bluesky identity, play real-time 1v1 chess with game state stored as atproto records, move updates via Jetstream. No application database, no server-side game logic.
1212+1313+See `SPEC.md` for full technical specification.
1414+1515+## Stack
1616+1717+- **Framework:** SvelteKit (static adapter)
1818+- **Chess logic:** chess.js (client-side validation, PGN generation)
1919+- **Board UI:** @lichess-org/chessground (Lichess BSD-licensed board)
2020+- **Auth:** @atproto/oauth-client-browser (sessions in IndexedDB)
2121+- **AT Protocol:** @atproto/api (read/write records to PDS)
2222+- **Real-time:** Jetstream WebSocket (opponent move events)
2323+- **Queries:** Constellation (game discovery), Slingshot (handle/DID resolution)
2424+- **Styling:** Tailwind CSS
2525+- **Domain:** checkmate.blue
2626+2727+## Key Architecture Decisions
2828+2929+- No app server or database. SvelteKit app deploys as a static site.
3030+- Each player writes moves to their OWN repo (atproto permissions are per-user). Both players maintain their own game record with full PGN.
3131+- White's game record is the "primary" record; Black's record has a `parentGameUri` pointing back to it. The game URL always uses White's DID and rkey.
3232+- On load, both records are read and the longer PGN is used (since each record is one move behind 50% of the time).
3333+- PGN is the source of truth for game state.
3434+- Move validation is client-side only (acceptable for PoC).
3535+- Jetstream delivers real-time move updates; falls back to polling on disconnect.
3636+3737+## Lexicons
3838+3939+Namespace: `blue.checkmate.*`
4040+4141+- `blue.checkmate.game` -- chess game record (key: tid)
4242+- `blue.checkmate.challenge` -- challenge to play (key: tid)
4343+4444+Lexicon definitions live in `lexicons/`.
4545+4646+## Commands
4747+4848+```bash
4949+npm run dev # Start dev server
5050+npm run build # Build for production
5151+npm run preview # Preview production build
5252+npm run check # Type check
5353+```
5454+5555+## Development Notes
5656+5757+- Vite dev server binds to `127.0.0.1` (not localhost) -- required for atproto OAuth loopback client.
5858+- In dev mode, OAuth uses `clientMetadata: undefined` (loopback client). In prod, it uses the full metadata object.
5959+- OAuth client-metadata.json is prerendered at `/oauth/client-metadata.json` via a `+server.ts` route.
6060+- SPA mode: `ssr = false` in root `+layout.ts`, static adapter with `fallback: 'index.html'`.
6161+- Svelte 5 runes: state files use `.svelte.ts` extension and `$state` instead of traditional stores.
6262+- Chessground requires 3 CSS imports (base, brown, cburnett) -- all imported in `layout.css`.
6363+6464+## Game Record Pairing
6565+6666+When Black joins a game, they create their own `blue.checkmate.game` record on their PDS with `parentGameUri` set to White's game AT-URI. This pairing is how each side finds the other's record:
6767+6868+- **White finds Black's record:** `findGameRecordByParent(agent, blackDid, parentUri)` searches Black's PDS for a record whose `parentGameUri` matches.
6969+- **Black finds their own record:** Same function against their own PDS.
7070+7171+When White discovers Black via Jetstream (`waitForOpponent`), White persists `{ black: blackDid, status: 'active' }` to their own record so the pairing survives page reload.
7272+7373+## Specs
7474+7575+Feature specs live in `specs/` and follow the `/spec` workflow. Treat specs as source of truth for implementation. If something isn't covered by a spec, ask rather than guessing.
+72
README.md
···11# checkmate.blue
22+33+Federated chess on [AT Protocol](https://atproto.com). Play real-time 1v1 chess using your Bluesky identity -- no accounts to create, no server to trust. Game state lives in each player's personal data repository.
44+55+**https://checkmate.blue**
66+77+## How it works
88+99+1. Sign in with your Bluesky account (via AT Protocol OAuth)
1010+2. Challenge another player by handle or accept an open challenge
1111+3. Play chess -- moves are written to your PDS as `blue.checkmate.game` records
1212+4. Your opponent's moves arrive in real-time via [Jetstream](https://docs.bsky.app/blog/jetstream)
1313+1414+There is no application server. The entire app is a static site that talks directly to each player's PDS. Each player maintains their own copy of the game record with the full PGN. The board reconciles state by reading both records and using the one with more moves.
1515+1616+## Stack
1717+1818+| Layer | Technology |
1919+|-------|-----------|
2020+| Framework | [SvelteKit](https://svelte.dev) (static adapter) |
2121+| Chess logic | [chess.js](https://github.com/jhlywa/chess.js) |
2222+| Board UI | [@lichess-org/chessground](https://github.com/lichess-org/chessground) |
2323+| Auth | [@atproto/oauth-client-browser](https://github.com/bluesky-social/atproto) |
2424+| Data | [AT Protocol](https://atproto.com) records on each player's PDS |
2525+| Real-time | [Jetstream](https://docs.bsky.app/blog/jetstream) WebSocket |
2626+| Styling | [Tailwind CSS](https://tailwindcss.com) |
2727+2828+## Development
2929+3030+```bash
3131+npm install
3232+npm run dev # Dev server on 127.0.0.1:5173
3333+npm run build # Production build
3434+npm run preview # Preview production build
3535+npm run check # Type check
3636+```
3737+3838+The dev server binds to `127.0.0.1` (not `localhost`) because atproto OAuth loopback clients require an IP address.
3939+4040+## Lexicons
4141+4242+Custom AT Protocol lexicons under the `blue.checkmate` namespace:
4343+4444+- **`blue.checkmate.game`** -- A chess game record containing PGN, player DIDs, status, and optional time control data.
4545+- **`blue.checkmate.challenge`** -- A challenge record linking to a game, used to invite a specific opponent.
4646+4747+Definitions live in [`lexicons/`](lexicons/).
4848+4949+## Architecture
5050+5151+```
5252+Player A (White) Player B (Black)
5353++-----------------+ +-----------------+
5454+| Browser | | Browser |
5555+| - chess.js | | - chess.js |
5656+| - chessground | | - chessground |
5757++--------+--------+ +--------+--------+
5858+ | |
5959+ write moves write moves
6060+ | |
6161+ v v
6262++--------+--------+ +--------+--------+
6363+| Player A's PDS | | Player B's PDS |
6464+| game record |<--- Jetstream ------->| game record |
6565+| (primary) | (real-time) | (parentGameUri) |
6666++-----------------+ +-----------------+
6767+```
6868+6969+Each player writes moves only to their own PDS (atproto enforces per-user write permissions). Both records contain the full PGN. On load, the app reads both records and uses the longer PGN, since each record is one move behind on the opponent's turns.
7070+7171+## License
7272+7373+MIT
+15
lexicons/README.md
···11+# Lexicons
22+33+AT Protocol lexicon definitions for checkmate.blue. Namespace: `blue.checkmate.*`
44+55+## Records
66+77+### `blue.checkmate.game`
88+99+Chess game record. Both players maintain their own copy -- White's is the primary, Black's links back via `parentGameUri`. PGN is the source of truth for game state; updated via `putRecord` on each move.
1010+1111+`timeControl` and `moveTimes` are defined but not yet implemented. All games are currently untimed.
1212+1313+### `blue.checkmate.challenge`
1414+1515+Challenge to play. Can target a specific opponent (by DID) or be left open. Once accepted, `gameUri` is set and `status` moves to `accepted`.
+36
lexicons/blue.checkmate.challenge.json
···11+{
22+ "lexicon": 1,
33+ "id": "blue.checkmate.challenge",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "tid",
88+ "description": "A challenge to play chess on checkmate.blue",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["createdAt", "status"],
1212+ "properties": {
1313+ "createdAt": {
1414+ "type": "string",
1515+ "format": "datetime"
1616+ },
1717+ "opponent": {
1818+ "type": "string",
1919+ "format": "did",
2020+ "description": "DID of the specific opponent, or omit for open challenge"
2121+ },
2222+ "gameUri": {
2323+ "type": "string",
2424+ "format": "at-uri",
2525+ "description": "AT URI of the game record once accepted"
2626+ },
2727+ "status": {
2828+ "type": "string",
2929+ "knownValues": ["open", "accepted", "expired", "cancelled"],
3030+ "description": "Current challenge status"
3131+ }
3232+ }
3333+ }
3434+ }
3535+ }
3636+}
+95
lexicons/blue.checkmate.game.json
···11+{
22+ "lexicon": 1,
33+ "id": "blue.checkmate.game",
44+ "defs": {
55+ "timeControl": {
66+ "type": "object",
77+ "description": "Time control settings for the game",
88+ "required": ["type"],
99+ "properties": {
1010+ "type": {
1111+ "type": "string",
1212+ "knownValues": ["untimed", "correspondence", "clock"],
1313+ "description": "Type of time control"
1414+ },
1515+ "initialSeconds": {
1616+ "type": "integer",
1717+ "description": "Starting time per player in seconds"
1818+ },
1919+ "incrementSeconds": {
2020+ "type": "integer",
2121+ "description": "Seconds added after each move (Fischer increment)"
2222+ },
2323+ "moveTimeLimitSeconds": {
2424+ "type": "integer",
2525+ "description": "Max seconds per move for correspondence games"
2626+ }
2727+ }
2828+ },
2929+ "main": {
3030+ "type": "record",
3131+ "key": "tid",
3232+ "description": "A chess game on checkmate.blue",
3333+ "record": {
3434+ "type": "object",
3535+ "required": ["pgn", "createdAt", "status"],
3636+ "properties": {
3737+ "pgn": {
3838+ "type": "string",
3939+ "maxLength": 100000,
4040+ "description": "PGN of the game including headers and moves so far"
4141+ },
4242+ "createdAt": {
4343+ "type": "string",
4444+ "format": "datetime"
4545+ },
4646+ "white": {
4747+ "type": "string",
4848+ "format": "did",
4949+ "description": "DID of the white player"
5050+ },
5151+ "black": {
5252+ "type": "string",
5353+ "format": "did",
5454+ "description": "DID of the black player"
5555+ },
5656+ "status": {
5757+ "type": "string",
5858+ "knownValues": ["waiting", "active", "completed", "abandoned"],
5959+ "description": "Current game status"
6060+ },
6161+ "result": {
6262+ "type": "string",
6363+ "knownValues": ["1-0", "0-1", "1/2-1/2"],
6464+ "description": "Game result in standard PGN notation"
6565+ },
6666+ "resultReason": {
6767+ "type": "string",
6868+ "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"],
6969+ "description": "Reason for the game result"
7070+ },
7171+ "parentGameUri": {
7272+ "type": "string",
7373+ "format": "at-uri",
7474+ "description": "AT URI of the white player's game record, set on the black player's copy"
7575+ },
7676+ "drawOffered": {
7777+ "type": "boolean",
7878+ "description": "Whether a draw has been offered by the last moving player"
7979+ },
8080+ "timeControl": {
8181+ "type": "ref",
8282+ "ref": "#timeControl"
8383+ },
8484+ "moveTimes": {
8585+ "type": "array",
8686+ "items": {
8787+ "type": "integer"
8888+ },
8989+ "description": "Seconds elapsed for each half-move, in ply order (white move 1, black move 1, white move 2, ...)"
9090+ }
9191+ }
9292+ }
9393+ }
9494+ }
9595+}