Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Add Phase 6 polish: sounds, rematch, PGN export, abandon detection, PWA

- Sound effects for moves, captures, game end, and opponent notifications
- Rematch button after game completion (swaps colors)
- PGN download and Lichess analysis integration
- Abandoned game detection with 7-day inactivity threshold
- PWA manifest with theme color
- Draw offer state restored on page reload
- Fix waitForOpponent false activation when creator is black
- Lexicon: add lastMoveAt field and abandonment result reason
- Add project roadmap, spec, and implementation plans

+2659 -10
+234
ROADMAP.md
··· 1 + # checkmate.blue Roadmap 2 + 3 + ## Phase 1: Challenge & Invite Flow -- DONE 4 + 5 + Implemented on branch `phase-1-challenge-invite-flow`. 6 + 7 + - **1a. Post-Based Challenges** -- "Post to Bluesky" button on the game waiting screen with @mention facets and game link. 8 + - **1b. DM-Based Challenges** -- "Send via DM" button copies game link to clipboard and opens bsky.app/messages. Full API-driven DMs deferred (requires `transition:chat.bsky` scope). 9 + - **1c. Color Selection** -- White/Black/Random picker on `/play`. Game page logic fully decoupled from record ownership. 10 + - **Check-before-join guard** -- re-fetches game record before joining to reduce race conditions on shared links. 11 + 12 + See `plans/phase-1-challenge-invite-flow.md` for full details. 13 + 14 + --- 15 + 16 + ## Phase 3: Spectator Mode -- DONE 17 + 18 + Pulled forward and implemented alongside Phase 1. 19 + 20 + - **Read-only game view** for non-participants with flip-board button. 21 + - **Unauthenticated viewing** -- no login required to watch a game. 22 + - **Dual Jetstream connections** -- spectators receive live updates from both players. 23 + - **Game-full fallback** -- extra visitors become spectators, not errors. 24 + 25 + See `plans/phase-3-spectator-mode.md` for full details. 26 + 27 + --- 28 + 29 + ## Phase 2: Shareability & Branding -- NEXT 30 + 31 + Priority: **High**. Links shared on social platforms currently show bare URLs. A recognizable brand and rich link previews drive organic growth. 32 + 33 + ### 2a. Logo 34 + 35 + FontAwesome chess knight icon in AT Protocol blue (`#0085FF`). 36 + 37 + **Usage:** 38 + - Favicon (SVG) 39 + - Navbar icon (32x32) 40 + - OG image fallback (centered on dark background) 41 + - PWA icon (192x192, 512x512) 42 + 43 + **Implementation:** 44 + - Create an SVG from the FA `chess-knight` glyph, colored `#0085FF`. 45 + - Generate PNG variants at required sizes for PWA manifest and Apple touch icon. 46 + - Replace the current placeholder favicon. 47 + 48 + ### 2b. Open Graph Meta Tags 49 + 50 + Rich link previews when sharing game/challenge URLs on Bluesky, Twitter, Discord, etc. 51 + 52 + **Problem:** The app is a pure SPA (SSR disabled, static adapter with `fallback: index.html`). Every route serves the same HTML shell, so crawlers see the same generic meta tags regardless of URL. 53 + 54 + **Options (in order of preference):** 55 + 56 + 1. **Cloudflare Worker in front of static site** -- intercept requests from known bot user agents, fetch game/challenge data from the PDS, return a minimal HTML page with correct `og:title`, `og:description`, `og:image` tags. All other requests pass through to the static SPA. 57 + 58 + 2. **OG image generation service** -- a separate edge function that renders a board position as a PNG. URL pattern: `checkmate.blue/og/{did}/{rkey}.png`. The Cloudflare Worker references these in the `og:image` tag. 59 + 60 + 3. **Static fallback only** -- use the same generic card (logo + "checkmate.blue - Chess on the Atmosphere") for all links. Easiest but least compelling. 61 + 62 + **Recommended approach:** Start with option 3 (generic card using the logo) and add the Cloudflare Worker (option 1) as a fast follow. The OG image generation (option 2) is nice-to-have. 63 + 64 + **Meta tags for different routes:** 65 + 66 + | Route | og:title | og:description | 67 + |-------|----------|---------------| 68 + | `/` | checkmate.blue | Chess on the Atmosphere | 69 + | `/game/{did}/{rkey}` | Chess Game - {white} vs {black} | {status} - {move count} moves | 70 + | `/challenge/{did}/{rkey}` | Chess Challenge from {handle} | {handle} wants to play chess | 71 + | `/profile/{handle}` | {handle} on checkmate.blue | Chess profile | 72 + 73 + --- 74 + 75 + ## Phase 4: Homepage & Game Discovery 76 + 77 + Priority: **Medium**. Makes the platform feel alive and gives new visitors something to see. 78 + 79 + ### 4a. Active Games List 80 + 81 + Show ongoing games on the homepage, sorted by most recent activity. 82 + 83 + **Deduplication:** Only show White's records (canonical). Black's records have `parentGameUri` set, so filter those out. A game record without `parentGameUri` and with `status: active` is a canonical active game. 84 + 85 + **Implementation:** 86 + 87 + - Query approach TBD -- depends on what Constellation supports for global queries. Options: 88 + - Query Constellation for all `blue.checkmate.game` records globally (if supported). 89 + - Maintain a known-players list and query each player's games (doesn't scale). 90 + - Use the Jetstream firehose to build a client-side index of recent games (complex, ephemeral). 91 + - Add a lightweight indexing endpoint (breaks the "no server" constraint). 92 + - Filter to `status: active` and `parentGameUri` absent (White's record only). 93 + - Sort by last activity. **Requires a `lastMoveAt` field on the lexicon** -- PGN parsing for sort order is too expensive for a list view. 94 + 95 + **Lexicon change:** 96 + ``` 97 + "lastMoveAt": { 98 + "type": "string", 99 + "format": "datetime", 100 + "description": "Timestamp of the most recent move" 101 + } 102 + ``` 103 + 104 + This field gets updated on every `putRecord` call when a move is made. 105 + 106 + - Display each game as a card: White handle vs Black handle, move count, last activity time, link to spectate. 107 + 108 + ### 4b. Completed Games Feed 109 + 110 + Below active games, show recently completed games with results. 111 + 112 + - Same query approach as active games, filtered to `status: completed`. 113 + - Show result (1-0, 0-1, draw), result reason, player handles. 114 + - Links to view the final position. 115 + 116 + --- 117 + 118 + ## Phase 5: Game Result Sharing 119 + 120 + Priority: **Medium**. The viral loop. Players share their wins (and losses) to Bluesky. 121 + 122 + **Implementation:** 123 + 124 + - After a game ends (checkmate, resignation, draw), show a "Share to Bluesky" button on the game page. 125 + - Create a `app.bsky.feed.post` record with: 126 + - Text describing the result: "Checkmate! I beat @{opponent} on checkmate.blue" / "Good game -- @{opponent} got me this time" / "Draw with @{opponent} on checkmate.blue" 127 + - Mention facet for the opponent 128 + - Link facet to the game URL 129 + - Embed with game link card (benefits from Phase 2 OG tags) 130 + - Include move count and result reason in the post text for flavor. 131 + - This is always opt-in. Show a pre-filled post that the player can edit before posting. 132 + 133 + **Note:** `src/lib/bluesky.ts` (created in Phase 1) already has `buildFacets()` and `postToBluesky()` which can be reused. The main new work is `composeGameResultPost()` and the editable textarea UI on the game page. 134 + 135 + --- 136 + 137 + ## Phase 6: Polish 138 + 139 + Priority: **Lower**. Quality-of-life improvements that make gameplay feel complete. 140 + 141 + ### 6a. Sound Effects 142 + 143 + - Move sound (piece placement) 144 + - Capture sound 145 + - Check sound 146 + - Game over sound (checkmate, draw) 147 + - Opponent move notification sound 148 + 149 + Use small audio files (MP3/OGG). Lichess sounds are BSD-licensed and could be used directly. 150 + 151 + ### 6b. Rematch Button 152 + 153 + After a game ends, show a "Rematch" button that creates a new game with the same opponent, colors swapped. 154 + 155 + - Creates a new `blue.checkmate.game` record with White/Black reversed. 156 + - The rematch is essentially a new challenge to the same opponent, auto-linked. 157 + - Could use the challenge record to track the rematch offer, or just create the game directly and show the link. 158 + 159 + ### 6c. PGN Export & Analysis 160 + 161 + - "Download PGN" button on completed (or in-progress) games. 162 + - "Analyze on Lichess" button that opens the Lichess analysis board with the game's PGN. 163 + - Lichess analysis URL: `https://lichess.org/analysis/pgn/{url-encoded-pgn}` 164 + 165 + ### 6d. Abandoned Game Detection 166 + 167 + - If a game has been inactive for a configurable period (e.g., 7 days), show an "Abandon" option to the waiting player. 168 + - The abandoning player writes `status: abandoned` to their record. 169 + - Could also auto-mark games as abandoned based on `lastMoveAt` (requires the lexicon change from Phase 4). 170 + - No server-side enforcement -- this is a convention. A bot (separate project) could handle automated cleanup. 171 + 172 + ### 6e. Mobile PWA 173 + 174 + - Add a `manifest.json` with app name, icons (from Phase 2a logo), theme color. 175 + - Add a service worker for offline shell caching. 176 + - The static SvelteKit build is already most of the way there. 177 + 178 + --- 179 + 180 + ## Phase 7: Open Challenge Board (Deferred) 181 + 182 + Priority: **Lower**. Allow players to browse and accept open challenges from anyone, not just targeted invites. 183 + 184 + - Add a public list of open challenges to the homepage via Constellation queries. 185 + - Requires investigation into whether Constellation supports global collection queries (not just DID-targeted). 186 + - Display open challenges with challenger handle, creation time. 187 + - Filter out expired challenges client-side (e.g., older than 24h). 188 + - Auto-expiry is a convention (UI hides old challenges), not enforced server-side. 189 + 190 + --- 191 + 192 + ## Separate Project: checkmate.blue Bot 193 + 194 + **Not part of this project.** Tracked here for reference. 195 + 196 + A Jetstream listener service that watches `blue.checkmate.game` records and posts game results from a `@checkmate.blue` bot account. Separate repo, separate deployment, runs as a persistent process (not a static site). 197 + 198 + Responsibilities: 199 + - Watch for `status: completed` game records on Jetstream. 200 + - Post game results to the bot's Bluesky feed. 201 + - Potentially: abandoned game detection, global game indexing, rating calculation. 202 + 203 + --- 204 + 205 + ## Lexicon Changes Summary 206 + 207 + Changes needed across the remaining roadmap: 208 + 209 + | Field | Lexicon | Phase | Status | 210 + |-------|---------|-------|--------| 211 + | `challengerColor` | `blue.checkmate.challenge` | 1c | DONE | 212 + | `lastMoveAt` | `blue.checkmate.game` | 4a | Pending | 213 + 214 + --- 215 + 216 + ## Dependency Graph 217 + 218 + ``` 219 + Phase 1 (Challenge Invite Flow) ── DONE 220 + Phase 3 (Spectator Mode) ── DONE 221 + 222 + Phase 2a (Logo) ─────────────────── prerequisite for 2b 223 + Phase 2b (OG Tags) ─────────────── benefits 5 224 + 225 + Phase 4a (Active Games List) ────── requires lexicon change (lastMoveAt) 226 + Phase 4b (Completed Games Feed) ── same query infrastructure as 4a 227 + 228 + Phase 5 (Game Result Sharing) ──── benefits from 2b (OG tags make shared links look good) 229 + reuses bluesky.ts from Phase 1 230 + 231 + Phase 6 (Polish) ───────────────── independent, can be interleaved anywhere 232 + 233 + Phase 7 (Open Challenge Board) ─── deferred, depends on Constellation global query support 234 + ```
+750
SPEC.md
··· 1 + # checkmate.blue — Technical Specification v2 2 + 3 + ## Overview 4 + 5 + **checkmate.blue** is a federated chess platform built entirely on AT Protocol. Players authenticate with their Bluesky identity, play real-time 1v1 chess with game state stored as atproto records, and receive move updates via Jetstream. There is no application database and no server-side game logic — the browser is the app, the PDS is the database, and the protocol is the infrastructure. 6 + 7 + **Target:** Working PoC in 2 days. 8 + 9 + --- 10 + 11 + ## Architecture 12 + 13 + ``` 14 + ┌─────────────────────────────────────────────────┐ 15 + │ Browser │ 16 + │ │ 17 + │ ┌──────────────┐ ┌─────────────────────────┐ │ 18 + │ │ SvelteKit │ │ @atproto/oauth-client │ │ 19 + │ │ UI + Pages │ │ -browser │ │ 20 + │ │ │ │ (IndexedDB sessions) │ │ 21 + │ └──────────────┘ └─────────────────────────┘ │ 22 + │ │ │ │ 23 + │ ┌──────────────┐ ┌─────────────────────────┐ │ 24 + │ │ chessground │ │ chess.js │ │ 25 + │ │ (board UI) │ │ (validation + PGN) │ │ 26 + │ └──────────────┘ └─────────────────────────┘ │ 27 + │ │ │ │ 28 + │ ▼ ▼ │ 29 + │ ┌───────────────────────────────────────────┐ │ 30 + │ │ atproto Agent (authenticated) │ │ 31 + │ │ • putRecord → player's PDS (write moves) │ │ 32 + │ │ • getRecord → opponent's PDS (read game) │ │ 33 + │ └───────────────────────────────────────────┘ │ 34 + │ │ │ │ 35 + │ ▼ ▼ │ 36 + │ ┌──────────────┐ ┌─────────────────────────┐ │ 37 + │ │ Jetstream │ │ Constellation / │ │ 38 + │ │ (WebSocket) │ │ Slingshot │ │ 39 + │ │ opponent │ │ (game discovery, │ │ 40 + │ │ move events │ │ handle resolution) │ │ 41 + │ └──────────────┘ └─────────────────────────┘ │ 42 + └─────────────────────────────────────────────────┘ 43 + │ │ 44 + ▼ ▼ 45 + ┌───────────┐ ┌─────────────────────┐ 46 + │ Jetstream │ │ Player's PDS │ 47 + │ (Bluesky) │ │ (e.g. bsky.social) │ 48 + └───────────┘ └─────────────────────┘ 49 + ``` 50 + 51 + **There is no application server, no database, no WebSocket server to run.** The SvelteKit app can be deployed as a static site. The only server-side requirement is hosting the OAuth client-metadata.json endpoint, which SvelteKit handles as a static route. 52 + 53 + --- 54 + 55 + ## Stack 56 + 57 + | Layer | Technology | 58 + |-------|-----------| 59 + | Framework | SvelteKit (static adapter or minimal node adapter) | 60 + | Chess logic | `chess.js` (client-side validation, PGN generation) | 61 + | Board UI | `chessground` (Lichess BSD-licensed board) | 62 + | Auth | `@atproto/oauth-client-browser` (sessions in IndexedDB) | 63 + | AT Protocol | `@atproto/api` (read/write records to PDS) | 64 + | Real-time | Jetstream WebSocket (listen for opponent's moves) | 65 + | Queries | Constellation (game discovery, backlinks) | 66 + | Identity | Slingshot (handle ↔ DID resolution, record cache) | 67 + | Styling | Tailwind CSS | 68 + | Domain | `checkmate.blue` | 69 + 70 + ### Key dependencies 71 + 72 + ```json 73 + { 74 + "dependencies": { 75 + "@atproto/api": "latest", 76 + "@atproto/oauth-client-browser": "latest", 77 + "chess.js": "latest", 78 + "chessground": "latest", 79 + "svelte": "latest", 80 + "@sveltejs/kit": "latest" 81 + }, 82 + "devDependencies": { 83 + "@sveltejs/adapter-static": "latest", 84 + "autoprefixer": "latest", 85 + "postcss": "latest", 86 + "tailwindcss": "latest", 87 + "typescript": "latest", 88 + "vite": "latest" 89 + } 90 + } 91 + ``` 92 + 93 + --- 94 + 95 + ## AT Protocol Lexicons 96 + 97 + Namespace: `blue.checkmate.*` 98 + 99 + ### Game Record — `blue.checkmate.game` 100 + 101 + A single record represents an entire game. Both players maintain their own copy -- White's record is the "primary" and Black's record includes a `parentGameUri` pointing back to it. Each record is updated with each move via `putRecord`. 102 + 103 + ```json 104 + { 105 + "lexicon": 1, 106 + "id": "blue.checkmate.game", 107 + "defs": { 108 + "timeControl": { 109 + "type": "object", 110 + "description": "Time control settings for the game (not yet implemented)", 111 + "required": ["type"], 112 + "properties": { 113 + "type": { 114 + "type": "string", 115 + "knownValues": ["untimed", "correspondence", "clock"] 116 + }, 117 + "initialSeconds": { 118 + "type": "integer", 119 + "description": "Starting time per player in seconds" 120 + }, 121 + "incrementSeconds": { 122 + "type": "integer", 123 + "description": "Seconds added after each move (Fischer increment)" 124 + }, 125 + "moveTimeLimitSeconds": { 126 + "type": "integer", 127 + "description": "Max seconds per move for correspondence games" 128 + } 129 + } 130 + }, 131 + "main": { 132 + "type": "record", 133 + "key": "tid", 134 + "description": "A chess game on checkmate.blue", 135 + "record": { 136 + "type": "object", 137 + "required": ["pgn", "createdAt", "status"], 138 + "properties": { 139 + "pgn": { 140 + "type": "string", 141 + "maxLength": 100000, 142 + "description": "PGN of the game including headers and moves so far" 143 + }, 144 + "createdAt": { 145 + "type": "string", 146 + "format": "datetime" 147 + }, 148 + "white": { 149 + "type": "string", 150 + "format": "did", 151 + "description": "DID of the white player" 152 + }, 153 + "black": { 154 + "type": "string", 155 + "format": "did", 156 + "description": "DID of the black player" 157 + }, 158 + "status": { 159 + "type": "string", 160 + "knownValues": ["waiting", "active", "completed", "abandoned"] 161 + }, 162 + "result": { 163 + "type": "string", 164 + "knownValues": ["1-0", "0-1", "1/2-1/2"] 165 + }, 166 + "resultReason": { 167 + "type": "string", 168 + "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"] 169 + }, 170 + "parentGameUri": { 171 + "type": "string", 172 + "format": "at-uri", 173 + "description": "AT URI of White's game record, set on Black's copy" 174 + }, 175 + "drawOffered": { 176 + "type": "boolean", 177 + "description": "Whether a draw has been offered by the last moving player" 178 + }, 179 + "timeControl": { 180 + "type": "ref", 181 + "ref": "#timeControl" 182 + }, 183 + "moveTimes": { 184 + "type": "array", 185 + "items": { "type": "integer" }, 186 + "description": "Seconds elapsed for each half-move, in ply order" 187 + } 188 + } 189 + } 190 + } 191 + } 192 + } 193 + ``` 194 + 195 + > **Note:** `timeControl` and `moveTimes` are defined in the lexicon but not yet implemented. All games are currently untimed. 196 + 197 + ### Challenge Record — `blue.checkmate.challenge` 198 + 199 + Created when a player wants to start a game. Contains a reference to who they're challenging (or left open for anyone). When accepted, a game record is created and the challenge is updated with a reference to it. 200 + 201 + ```json 202 + { 203 + "lexicon": 1, 204 + "id": "blue.checkmate.challenge", 205 + "defs": { 206 + "main": { 207 + "type": "record", 208 + "key": "tid", 209 + "description": "A challenge to play chess on checkmate.blue", 210 + "record": { 211 + "type": "object", 212 + "required": ["createdAt", "status"], 213 + "properties": { 214 + "createdAt": { 215 + "type": "string", 216 + "format": "datetime" 217 + }, 218 + "opponent": { 219 + "type": "string", 220 + "format": "did", 221 + "description": "DID of the specific opponent, or omit for open challenge" 222 + }, 223 + "gameUri": { 224 + "type": "string", 225 + "format": "at-uri", 226 + "description": "AT URI of the game record once accepted" 227 + }, 228 + "status": { 229 + "type": "string", 230 + "knownValues": ["open", "accepted", "expired", "cancelled"] 231 + } 232 + } 233 + } 234 + } 235 + } 236 + } 237 + ``` 238 + 239 + --- 240 + 241 + ## Authentication 242 + 243 + Use `@atproto/oauth-client-browser` for fully client-side OAuth. Sessions are stored in the browser's IndexedDB — no server-side session management needed. 244 + 245 + ### Setup 246 + 247 + ```typescript 248 + // src/lib/oauth.ts 249 + import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; 250 + 251 + export const oauthClient = new BrowserOAuthClient({ 252 + clientMetadata: { 253 + client_id: 'https://checkmate.blue/oauth/client-metadata.json', 254 + client_name: 'checkmate.blue', 255 + client_uri: 'https://checkmate.blue', 256 + redirect_uris: ['https://checkmate.blue/oauth/callback'], 257 + scope: 'atproto transition:generic', 258 + grant_types: ['authorization_code', 'refresh_token'], 259 + response_types: ['code'], 260 + application_type: 'web', 261 + dpop_bound_access_tokens: true, 262 + }, 263 + handleResolver: 'https://bsky.social', 264 + }); 265 + ``` 266 + 267 + For local development, use the loopback client pattern as specified in the atproto OAuth spec — `http://localhost` with any port is treated as a special development client that doesn't require a publicly accessible client-metadata endpoint. 268 + 269 + ### Flow 270 + 271 + 1. User clicks "Sign in with Bluesky" and enters their handle 272 + 2. `oauthClient.signIn(handle)` redirects to their PDS 273 + 3. User authorizes, PDS redirects back to `/oauth/callback` 274 + 4. `oauthClient.init()` on page load restores sessions from IndexedDB 275 + 5. Create an `Agent` from the session for all atproto operations 276 + 277 + ```typescript 278 + import { Agent } from '@atproto/api'; 279 + 280 + const session = await oauthClient.restore(did); 281 + const agent = new Agent(session); 282 + // agent is now authenticated — can read/write to user's PDS 283 + ``` 284 + 285 + ### Client Metadata Endpoint 286 + 287 + Serve a static JSON file at `/oauth/client-metadata.json`. This must be publicly accessible and match the `client_id` URL exactly. In SvelteKit, create this as a server route or static file. 288 + 289 + --- 290 + 291 + ## Game Flow 292 + 293 + ### 1. Create Challenge 294 + 295 + Player A creates a challenge record in their own repo: 296 + 297 + ```typescript 298 + const challenge = await agent.com.atproto.repo.createRecord({ 299 + repo: agent.session.did, 300 + collection: 'blue.checkmate.challenge', 301 + record: { 302 + $type: 'blue.checkmate.challenge', 303 + createdAt: new Date().toISOString(), 304 + status: 'open', 305 + // opponent: 'did:plc:bob' — optional, omit for open challenge 306 + }, 307 + }); 308 + // Share the AT URI or derive a challenge URL from it 309 + ``` 310 + 311 + The challenge URL is constructed from the AT URI: `https://checkmate.blue/challenge/{did}/{rkey}` 312 + 313 + ### 2. Accept Challenge 314 + 315 + Player B views the challenge, clicks accept. Player A's client creates the game record and updates the challenge: 316 + 317 + **Who creates the game record?** The challenger (Player A). Their client watches for challenge acceptance (via Jetstream or polling) and then: 318 + 319 + 1. Creates a `blue.checkmate.game` record in their repo with both players assigned 320 + 2. Updates the challenge record with the game URI and status `accepted` 321 + 322 + Alternatively, for simplicity in the PoC: the accepting player (Player B) calls an XRPC endpoint or the game creation is handled by whoever loads the game page first. The simplest approach: **Player A creates the game record immediately when creating the challenge**, with status `waiting`. Player B accepting just means they open the game URL and start playing. 323 + 324 + ```typescript 325 + // Player A creates game + challenge together 326 + const game = await agent.com.atproto.repo.createRecord({ 327 + repo: agent.session.did, 328 + collection: 'blue.checkmate.game', 329 + record: { 330 + $type: 'blue.checkmate.game', 331 + pgn: '[Event "checkmate.blue"]\n[White ""]\n[Black ""]\n[Result "*"]\n\n*', 332 + createdAt: new Date().toISOString(), 333 + white: agent.session.did, // challenger plays white 334 + status: 'waiting', 335 + moveCount: 0, 336 + }, 337 + }); 338 + // Share game URL: https://checkmate.blue/game/{did}/{rkey} 339 + ``` 340 + 341 + When Player B opens the URL, the game record is updated with their DID as black and status becomes `active`. 342 + 343 + ### 3. Making Moves 344 + 345 + The current player validates the move client-side with chess.js, then writes the updated game state to the **game owner's** repo. 346 + 347 + **Key design decision:** The game record lives in Player A's repo. Both players need write access to update it. But Player B can't write to Player A's repo — they can only write to their own. 348 + 349 + **Solution: Each player writes their moves to their OWN repo as a move record, and the opponent's client reads it.** 350 + 351 + Revised approach — **two records, one per player**: 352 + 353 + When the game starts, each player creates a `blue.checkmate.game` record in their own repo, referencing the same game. Moves are written to the moving player's record. The opponent watches for updates via Jetstream. 354 + 355 + ``` 356 + Player A's repo: 357 + blue.checkmate.game/{rkey-A} 358 + { white: A, black: B, pgn: "1. e4 e5 2. Nf3", lastMoveBy: A, ... } 359 + 360 + Player B's repo: 361 + blue.checkmate.game/{rkey-B} 362 + { white: A, black: B, pgn: "1. e4 e5 2. Nf3 Nc6", lastMoveBy: B, ... } 363 + ``` 364 + 365 + Each player updates THEIR OWN record with the full PGN after making a move. The opponent reads the updated PGN from the other player's record to see the new move, validates it, and then writes the updated PGN (including their reply) to their own record. 366 + 367 + **Move validation flow:** 368 + 369 + 1. Player A makes a move in the UI 370 + 2. Client validates with chess.js — is it legal? Is it A's turn? 371 + 3. Client updates Player A's game record with the new PGN via `putRecord` 372 + 4. Player B's client receives the update via Jetstream 373 + 5. Player B's client reads the updated PGN, validates the new move with chess.js 374 + 6. If valid, updates the local board state 375 + 7. Player B makes their move, writes to their own record 376 + 8. Player A receives via Jetstream, validates, updates board 377 + 378 + **Why two records?** Because atproto permissions are per-user. You can only write to your own repo. This is the natural pattern — each player maintains their own copy of the game state, and the protocol ensures both can be read by anyone. 379 + 380 + ### 4. Game Over 381 + 382 + When checkmate, stalemate, or draw is detected by chess.js, the current player writes the final state with `status: completed` and the `result` field filled in. Both players' records should reflect the final state. 383 + 384 + ### 5. Resignation / Draw 385 + 386 + Resign: player writes `status: completed`, `result` favoring opponent, `resultReason: resignation` to their own record. 387 + 388 + Draw offer: could be a field on the record (`drawOffered: true`) that the opponent reads and either accepts or ignores. On acceptance, both records get `result: 1/2-1/2`. 389 + 390 + --- 391 + 392 + ## Jetstream Integration 393 + 394 + Jetstream delivers real-time events from the atproto firehose over WebSocket. The client connects directly to Jetstream and filters for the opponent's record updates. 395 + 396 + ### Connection 397 + 398 + ```typescript 399 + // Connect to Jetstream, filtered by opponent's DID and our game collection 400 + const jetstreamUrl = new URL('wss://jetstream2.us-east.bsky.network/subscribe'); 401 + jetstreamUrl.searchParams.set('wantedCollections', 'blue.checkmate.game'); 402 + jetstreamUrl.searchParams.set('wantedDids', opponentDid); 403 + 404 + const ws = new WebSocket(jetstreamUrl.toString()); 405 + 406 + ws.onmessage = (event) => { 407 + const data = JSON.parse(event.data); 408 + 409 + if (data.kind === 'commit' && data.commit.operation === 'update') { 410 + // Opponent updated their game record — they made a move 411 + const record = data.commit.record; 412 + if (record.pgn) { 413 + // Validate the new move with chess.js 414 + // Update the local board 415 + } 416 + } 417 + }; 418 + ``` 419 + 420 + ### Jetstream Event Structure 421 + 422 + Events arrive as JSON with a `kind` field. For record updates: 423 + 424 + ```typescript 425 + { 426 + kind: 'commit', 427 + did: 'did:plc:opponent', 428 + commit: { 429 + operation: 'update', // or 'create', 'delete' 430 + collection: 'blue.checkmate.game', 431 + rkey: 'abc123', 432 + record: { /* the full updated record */ }, 433 + cid: 'bafyrei...' 434 + }, 435 + time_us: 1234567890 436 + } 437 + ``` 438 + 439 + ### Fallback Polling 440 + 441 + If Jetstream connection drops, fall back to polling `getRecord` every 3 seconds until WebSocket reconnects: 442 + 443 + ```typescript 444 + const response = await agent.com.atproto.repo.getRecord({ 445 + repo: opponentDid, 446 + collection: 'blue.checkmate.game', 447 + rkey: opponentRkey, 448 + }); 449 + ``` 450 + 451 + --- 452 + 453 + ## Microcosm Integration 454 + 455 + ### Constellation — Game Discovery 456 + 457 + Query Constellation to find all games a player is involved in, without maintaining a local index. 458 + 459 + ```typescript 460 + // Find all game records that link to a player's DID 461 + const response = await fetch( 462 + `https://constellation.microcosm.blue/links/all?` + 463 + `target=${encodeURIComponent(playerDid)}` + 464 + `&collection=blue.checkmate.game` 465 + ); 466 + ``` 467 + 468 + This returns all records across the network that reference the player's DID — i.e., games where they appear as `white` or `black`. 469 + 470 + ### Slingshot — Handle Resolution & Record Cache 471 + 472 + Resolve DIDs to handles (and vice versa) and get fast cached access to records: 473 + 474 + ```typescript 475 + // Resolve a DID to handle/profile info 476 + const response = await fetch( 477 + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?` + 478 + `identifier=${encodeURIComponent(did)}` 479 + ); 480 + ``` 481 + 482 + Useful for displaying opponent handles/avatars without querying the PDS directly. 483 + 484 + --- 485 + 486 + ## Pages & Routes 487 + 488 + ### `/` — Landing Page 489 + 490 + - Logo/mascot placeholder + tagline 491 + - "Sign in with Bluesky" button 492 + - If authenticated: player handle, avatar, quick actions 493 + - Active games list (fetched from Constellation or direct PDS query) 494 + 495 + ### `/play` — Create or Browse Challenges 496 + 497 + - Create a new challenge (open or directed at a specific handle) 498 + - Browse open challenges from other players (via Constellation backlinks on the player declaration records) 499 + 500 + ### `/challenge/{did}/{rkey}` — View / Accept Challenge 501 + 502 + - Shows who created the challenge 503 + - "Accept & Play" button 504 + - On accept: creates game records, redirects to game page 505 + 506 + ### `/game/{did}/{rkey}` — Active Game 507 + 508 + The main gameplay page. The URL contains the game owner's DID and record key. 509 + 510 + **Layout (mobile-first):** 511 + 512 + ``` 513 + ┌──────────────────────────┐ 514 + │ Opponent handle + avatar │ 515 + ├──────────────────────────┤ 516 + │ │ 517 + │ Chessground Board │ 518 + │ (responsive, square) │ 519 + │ │ 520 + ├──────────────────────────┤ 521 + │ Your handle + avatar │ 522 + ├──────────────────────────┤ 523 + │ [Resign] [Offer Draw] │ 524 + └──────────────────────────┘ 525 + ``` 526 + 527 + Desktop: board centered, player info and move list in a sidebar. 528 + 529 + **Chessground integration:** 530 + 531 + ```typescript 532 + import { Chessground } from 'chessground'; 533 + import 'chessground/assets/chessground.base.css'; 534 + import 'chessground/assets/chessground.brown.css'; 535 + import 'chessground/assets/chessground.cburnett.css'; 536 + 537 + const ground = Chessground(boardElement, { 538 + fen: currentFen, 539 + orientation: playerColor, 540 + turnColor: turnColor, 541 + movable: { 542 + free: false, 543 + color: playerColor, 544 + dests: legalMoves, // computed from chess.js 545 + }, 546 + events: { 547 + move: (orig, dest) => handleMove(orig, dest), 548 + }, 549 + }); 550 + ``` 551 + 552 + **Promotion handling:** When a pawn reaches the 8th rank, show a modal with piece choices (Q/R/B/N) before writing the move. 553 + 554 + ### `/profile/{handle}` — Player Profile (stretch) 555 + 556 + - Display name, handle, avatar from atproto profile 557 + - Game history via Constellation queries 558 + - Win/loss/draw record 559 + 560 + ### `/oauth/callback` — OAuth Redirect 561 + 562 + Handles the OAuth callback. The `BrowserOAuthClient` processes the redirect params and stores the session in IndexedDB. 563 + 564 + ### `/oauth/client-metadata.json` — OAuth Client Metadata 565 + 566 + Static JSON endpoint required by the atproto OAuth spec. Must be publicly accessible. 567 + 568 + --- 569 + 570 + ## File Structure 571 + 572 + ``` 573 + checkmate-blue/ 574 + ├── src/ 575 + │ ├── lib/ 576 + │ │ ├── oauth.ts # BrowserOAuthClient setup 577 + │ │ ├── atproto.ts # Agent creation, record read/write helpers 578 + │ │ ├── game-logic.ts # chess.js wrapper, move validation, PGN ops 579 + │ │ ├── jetstream.ts # Jetstream WebSocket connection + filtering 580 + │ │ ├── microcosm.ts # Constellation + Slingshot query helpers 581 + │ │ ├── stores/ 582 + │ │ │ ├── auth.ts # Current user / agent store 583 + │ │ │ ├── game.ts # Active game state store 584 + │ │ │ └── jetstream.ts # Jetstream connection state 585 + │ │ ├── components/ 586 + │ │ │ ├── Board.svelte # Chessground wrapper 587 + │ │ │ ├── PlayerBar.svelte # Avatar + handle display 588 + │ │ │ ├── GameControls.svelte # Resign, draw offer buttons 589 + │ │ │ ├── PromotionModal.svelte 590 + │ │ │ ├── MoveList.svelte # PGN move list display 591 + │ │ │ └── LoginButton.svelte # "Sign in with Bluesky" 592 + │ │ └── types.ts # Shared TypeScript types 593 + │ ├── routes/ 594 + │ │ ├── +layout.svelte # Global layout, auth init 595 + │ │ ├── +page.svelte # Landing page 596 + │ │ ├── play/ 597 + │ │ │ └── +page.svelte # Create / browse challenges 598 + │ │ ├── challenge/ 599 + │ │ │ └── [did]/ 600 + │ │ │ └── [rkey]/ 601 + │ │ │ └── +page.svelte # View / accept challenge 602 + │ │ ├── game/ 603 + │ │ │ └── [did]/ 604 + │ │ │ └── [rkey]/ 605 + │ │ │ └── +page.svelte # Main game board 606 + │ │ ├── profile/ 607 + │ │ │ └── [handle]/ 608 + │ │ │ └── +page.svelte # Player profile 609 + │ │ └── oauth/ 610 + │ │ ├── callback/ 611 + │ │ │ └── +page.svelte # OAuth callback handler 612 + │ │ └── client-metadata.json/ 613 + │ │ └── +server.ts # Serve OAuth client metadata 614 + │ └── app.css # Tailwind + chessground overrides 615 + ├── lexicons/ 616 + │ ├── blue.checkmate.game.json 617 + │ └── blue.checkmate.challenge.json 618 + ├── static/ 619 + │ ├── favicon.svg 620 + │ └── mascot-placeholder.svg 621 + ├── svelte.config.js 622 + ├── tailwind.config.ts 623 + ├── tsconfig.json 624 + └── package.json 625 + ``` 626 + 627 + --- 628 + 629 + ## Design System 630 + 631 + ### Visual Direction 632 + 633 + Dark theme, blue accents. Minimal, clean, mobile-first. 634 + 635 + ### Mascot / Logo 636 + 637 + Goose wearing a chess crown — inspired by ATmosphereConf's "Goodstuff Goosetopher." Art to be provided separately (permission pending from original artist). Design all layout slots to accommodate a square mascot image at 48x48 (nav), 128x128 (hero), 32x32 (favicon). 638 + 639 + ### Color Palette 640 + 641 + ```css 642 + :root { 643 + --bg-primary: #0f1419; 644 + --bg-secondary: #1a2332; 645 + --bg-board: #2a3a4a; 646 + --accent-blue: #1d9bf0; 647 + --accent-blue-hover: #1a8cd8; 648 + --text-primary: #e7e9ea; 649 + --text-secondary: #71767b; 650 + --success: #00ba7c; 651 + --danger: #f4212e; 652 + --warning: #ffd400; 653 + --border: #2f3336; 654 + } 655 + ``` 656 + 657 + ### Typography 658 + 659 + System sans-serif stack for body and headings. Monospace (`JetBrains Mono` or system monospace) for move notation. 660 + 661 + --- 662 + 663 + ## Implementation Order (2-Day Plan) 664 + 665 + ### Day 1: Auth + Board + Protocol Writes 666 + 667 + **Morning:** 668 + 1. Scaffold SvelteKit project from Bailey's atproto template or flo-bit's client-side OAuth scaffold 669 + 2. Adapt OAuth for `@atproto/oauth-client-browser` 670 + 3. Serve `client-metadata.json` endpoint 671 + 4. Test: can log in with a Bluesky account, get an authenticated agent 672 + 673 + **Afternoon:** 674 + 5. Integrate chessground Board component with chess.js 675 + 6. Implement `createRecord` for game creation 676 + 7. Implement `putRecord` for move writes 677 + 8. Build game page that loads game state from PDS on mount 678 + 9. Test: can create a game, make moves, see them written to PDS 679 + 680 + ### Day 2: Real-time + Polish 681 + 682 + **Morning:** 683 + 10. Connect to Jetstream, filter for opponent's DID + game collection 684 + 11. When Jetstream delivers an update, validate and apply the opponent's move 685 + 12. Add fallback polling for when Jetstream disconnects 686 + 13. Test: two browsers, two accounts, can play a full game in real-time 687 + 688 + **Afternoon:** 689 + 14. Challenge creation and acceptance flow 690 + 15. Resign / draw offer 691 + 16. Landing page with branding and auth 692 + 17. Responsive layout polish (mobile board sizing) 693 + 18. Deploy to Linode (or static host), configure domain 694 + 19. Swap in mascot art if available 695 + 696 + --- 697 + 698 + ## Edge Cases & Considerations 699 + 700 + - **Move validation is client-side only.** A malicious client could write illegal moves. Acceptable for the PoC. A future version could add a server-side validation proxy. 701 + - **PGN is the source of truth.** Both players maintain their own record with the full PGN. If records diverge (e.g., due to a bug or tampering), the PGN can be diffed to find where they forked. 702 + - **Jetstream may lag.** Events typically arrive within 1-2 seconds. If faster feedback is needed in the future, consider a lightweight signaling WebSocket alongside the protocol writes. 703 + - **Record conflicts.** Since it's turn-based and each player only writes to their own record, there are no concurrent write conflicts. 704 + - **PDS rate limits.** Writing one record per move is fine. A typical game is 40 moves, so 40 `putRecord` calls spread over minutes to hours. Well within any rate limit. 705 + - **Offline / disconnect.** If a player closes their browser, the game state is on the PDS. They can reopen the game URL, read the current PGN, and continue. No session to restore. 706 + - **OAuth token expiry.** `@atproto/oauth-client-browser` handles token refresh automatically via IndexedDB. Shorter token lifetimes (public client) are fine for gameplay sessions. 707 + 708 + --- 709 + 710 + ## Deployment 711 + 712 + ### Static Hosting (simplest) 713 + 714 + Since there's no server-side logic beyond serving static files and the client-metadata endpoint, the app can be deployed to: 715 + - **Vercel** — SvelteKit has a Vercel adapter 716 + - **Cloudflare Pages** — near-zero latency 717 + - **GitHub Pages** — free, simple 718 + - **Linode** — if you want full control, serve via Caddy 719 + 720 + The `client-metadata.json` must be served at the exact URL matching the `client_id`. If using a static adapter, ensure this route is handled. 721 + 722 + ### Custom Domain 723 + 724 + Configure `checkmate.blue` DNS to point to the hosting provider. If using Caddy on Linode: 725 + 726 + ```caddyfile 727 + checkmate.blue { 728 + root * /var/www/checkmate-blue 729 + file_server 730 + try_files {path} /index.html # SPA fallback 731 + } 732 + ``` 733 + 734 + --- 735 + 736 + ## What This Spec Does NOT Cover (v2+) 737 + 738 + - Server-side move validation 739 + - ELO rating calculation 740 + - Spectator mode 741 + - Tournament system 742 + - Full chess clocks / time controls 743 + - Anti-cheat 744 + - Custom PDS or relay 745 + - Chat / messaging 746 + - Sound effects 747 + - Custom board themes / piece sets 748 + - Move timer / timeout enforcement 749 + - Game search / filtering beyond Constellation queries 750 + - Bluesky post integration (sharing game results)
+6 -1
lexicons/blue.checkmate.game.json
··· 65 65 }, 66 66 "resultReason": { 67 67 "type": "string", 68 - "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"], 68 + "knownValues": ["checkmate", "resignation", "agreement", "stalemate", "insufficient", "repetition", "fifty_moves", "abandonment"], 69 69 "description": "Reason for the game result" 70 + }, 71 + "lastMoveAt": { 72 + "type": "string", 73 + "format": "datetime", 74 + "description": "Timestamp of the most recent move" 70 75 }, 71 76 "parentGameUri": { 72 77 "type": "string",
+166
plans/e2e-test-plan.md
··· 1 + # E2E Test Plan -- Phase 1 + Spectator Mode 2 + 3 + Manual tests for the changes on the `phase-1-challenge-invite-flow` branch. You need two Bluesky accounts and two browser windows (or one regular + one incognito). 4 + 5 + **Accounts:** Call them Account A and Account B. Use separate browser profiles or windows so their sessions don't conflict. 6 + 7 + --- 8 + 9 + ## Test 1: Targeted Challenge (White) 10 + 11 + **Tests:** Game creation with color selection, opponent resolution, challenge acceptance, basic gameplay. 12 + 13 + 1. Sign in as Account A. 14 + 2. Go to `/play`. 15 + 3. Enter Account B's handle in the opponent field. 16 + 4. Leave color selection on "White" (default). 17 + 5. Click "Challenge Player". 18 + 6. Verify you land on `/game/{A-did}/{rkey}` with the "Waiting for opponent" panel. 19 + 7. Verify the link input shows the correct game URL. 20 + 8. Verify the "Post to Bluesky" and "Send via DM" buttons are visible. 21 + 9. In the second browser, sign in as Account B. 22 + 10. Navigate to the game URL from step 6. 23 + 11. **Expected:** Account B joins as Black. The board shows from Black's perspective. The "Waiting for opponent" panel disappears on Account A's screen. 24 + 12. Make a move as Account A (White). Verify Account B's board updates within a few seconds. 25 + 13. Make a move as Account B (Black). Verify Account A's board updates. 26 + 27 + --- 28 + 29 + ## Test 2: Targeted Challenge (Black) 30 + 31 + **Tests:** Challenger-as-black color assignment. 32 + 33 + 1. Sign in as Account A. Go to `/play`. 34 + 2. Enter Account B's handle. 35 + 3. Select "Black" in the color selector. 36 + 4. Click "Challenge Player". 37 + 5. Verify you land on the game page. The board should show from Black's perspective (black pieces at bottom). 38 + 6. In the second browser, sign in as Account B and navigate to the game URL. 39 + 7. **Expected:** Account B joins as White. Their board shows from White's perspective. 40 + 8. Account B makes the first move (they're White). Verify Account A sees it. 41 + 9. Account A makes a move. Verify Account B sees it. 42 + 43 + --- 44 + 45 + ## Test 3: Random Color 46 + 47 + **Tests:** Random color assignment. 48 + 49 + 1. Sign in as Account A. Go to `/play`. 50 + 2. Enter Account B's handle. 51 + 3. Select "Random". 52 + 4. Click "Challenge Player". 53 + 5. Note which color Account A was assigned (check the board orientation). 54 + 6. Have Account B join via the game URL. 55 + 7. **Expected:** Account B gets the opposite color. Both boards orient correctly. 56 + 57 + --- 58 + 59 + ## Test 4: Post Challenge to Bluesky 60 + 61 + **Tests:** Bluesky post creation with mention facet. 62 + 63 + 1. Create a targeted challenge (any color) as Account A. 64 + 2. On the waiting screen, click "Post to Bluesky". 65 + 3. **Expected:** Button changes to "Posted!". 66 + 4. Open Account A's Bluesky profile (bsky.app). Verify a post exists mentioning Account B with the game link. 67 + 5. On Account B's Bluesky, verify they received a notification (mention). 68 + 6. Click the game link in the post. **Expected:** It navigates to the game page. 69 + 70 + --- 71 + 72 + ## Test 5: Send via DM 73 + 74 + **Tests:** DM workflow (clipboard + messages page). 75 + 76 + 1. Create a targeted challenge as Account A. 77 + 2. On the waiting screen, click "Send via DM". 78 + 3. **Expected:** Button text changes to "Link copied! Paste in DM". A new tab opens to `bsky.app/messages`. 79 + 4. Verify the game URL is in your clipboard (paste it somewhere to check). 80 + 5. In the Bluesky messages tab, find or start a conversation with Account B and paste the link. 81 + 82 + --- 83 + 84 + ## Test 6: Spectator Mode (Logged In) 85 + 86 + **Tests:** Third-party viewing of an active game. 87 + 88 + 1. Start a game between Account A and Account B (use a targeted challenge, have both join). 89 + 2. Make a few moves so the game is active. 90 + 3. Open a third browser window (or incognito). Sign in as a third account (or use Account A/B on a different game they're not part of). 91 + 4. Navigate to the game URL. 92 + 5. **Expected:** 93 + - Board loads showing the current position from White's perspective. 94 + - "Spectating" label is visible. 95 + - "Flip board" button is visible. 96 + - No resign/draw buttons. 97 + - No legal move indicators when hovering pieces. 98 + - The board is not interactive (can't drag pieces). 99 + 6. Click "Flip board". **Expected:** Board orientation toggles to Black's perspective. 100 + 7. Have one of the players make a move. **Expected:** The spectator's board updates in real-time. 101 + 102 + --- 103 + 104 + ## Test 7: Spectator Mode (Not Logged In) 105 + 106 + **Tests:** Unauthenticated game viewing. 107 + 108 + 1. Start an active game between Account A and Account B. 109 + 2. Open a fresh incognito window (not signed in). 110 + 3. Navigate to the game URL. 111 + 4. **Expected:** 112 + - Board loads with the current position. No sign-in prompt blocking the view. 113 + - "Spectating" label and "Flip board" button visible. 114 + - "Live" connection indicator visible (Jetstream connects for spectators). 115 + - A small "Sign in to play" section appears below the board (not blocking the view). 116 + 5. Have a player make a move. **Expected:** Spectator board updates live. 117 + 118 + --- 119 + 120 + ## Test 8: Game Full -- Graceful Fallback 121 + 122 + **Tests:** Multiple people clicking the same game link. 123 + 124 + 1. Account A creates an open game (no opponent handle, just click "Create Open Game"). 125 + 2. Share the game URL with Account B. 126 + 3. Account B navigates to the URL and joins (fills the empty slot). 127 + 4. Now open the same URL in a third browser/profile, signed in as a different account. 128 + 5. **Expected:** The third person sees the game in spectator mode, not an error. Both player slots are already filled. 129 + 130 + --- 131 + 132 + ## Test 9: Check-Before-Join Guard 133 + 134 + **Tests:** Race condition mitigation when two people try to join simultaneously. 135 + 136 + This is hard to trigger manually but you can approximate it: 137 + 138 + 1. Account A creates an open game. 139 + 2. Account B opens the game URL but does NOT let it finish loading (throttle network in dev tools, or just be ready). 140 + 3. Meanwhile, have Account C open the same URL and let it load fully -- Account C joins. 141 + 4. Now let Account B's page finish loading. 142 + 5. **Expected:** Account B sees the game in spectator mode (the slot was filled by C between B's two fetches). 143 + 144 + --- 145 + 146 + ## Test 10: Completed Game Viewing 147 + 148 + **Tests:** Spectators can view finished games. 149 + 150 + 1. Play a game to completion (checkmate, or have one player resign). 151 + 2. Verify both players see the result. 152 + 3. Open the game URL in an incognito window (not logged in). 153 + 4. **Expected:** Board shows the final position. Result is displayed (e.g., "White wins -- checkmate"). No interactive elements. "Spectating" label visible. 154 + 155 + --- 156 + 157 + ## Quick Checks 158 + 159 + These don't need a full walkthrough but are worth verifying: 160 + 161 + - [ ] The color selector on `/play` visually highlights the selected option. 162 + - [ ] "Copy" button on the waiting screen copies the correct URL and shows "Copied!" briefly. 163 + - [ ] Connection indicator shows "Live" (green dot) when Jetstream is connected. 164 + - [ ] The Bluesky post from Test 4 has a clickable @mention (not plain text). 165 + - [ ] Navigating away from a game page and back doesn't create duplicate Jetstream connections (check console for `[jetstream] connecting` logs). 166 + - [ ] An existing game where the owner was always White (pre-color-selection) still loads correctly.
+70
plans/phase-1-challenge-invite-flow.md
··· 1 + # Phase 1: Challenge & Invite Flow 2 + 3 + Status: **Implemented** (branch: `phase-1-challenge-invite-flow`) 4 + 5 + --- 6 + 7 + ## What Was Implemented 8 + 9 + ### 1a. Post-Based Challenges -- DONE 10 + 11 + "Post to Bluesky" button on the game waiting screen. Creates a `app.bsky.feed.post` in the challenger's repo with: 12 + - Text mentioning the opponent: "I'm challenging @{handle} to a game of chess on checkmate.blue!" 13 + - Mention facet with correct UTF-8 byte offsets 14 + - Link facet for the game URL 15 + - `app.bsky.embed.external` with title and description for the link card 16 + 17 + Only shows when a specific opponent is set (not for open games). Button disables after posting to prevent double-posts. 18 + 19 + **Files created:** 20 + - `src/lib/bluesky.ts` -- `buildFacets()`, `postToBluesky()`, `composeChallengePost()` 21 + - `tests/lib/bluesky.test.ts` -- 9 tests covering facet construction, byte offsets with unicode, handle edge cases 22 + 23 + **Files modified:** 24 + - `src/routes/game/[did]/[rkey]/+page.svelte` -- added post button in waiting state 25 + 26 + ### 1b. DM-Based Challenges -- DONE (simplified) 27 + 28 + Originally planned as a `chat.bsky.convo` API integration. Investigation revealed that `transition:generic` scope does NOT cover `chat.bsky.*` operations -- adding DM support would require the `transition:chat.bsky` scope, broadening the permission grant. 29 + 30 + **Implemented instead:** "Send via DM" button that copies the game link to clipboard and opens `https://bsky.app/messages` in a new tab. The user pastes the link in the conversation. Not as seamless as API-driven DMs, but works without additional OAuth scope. 31 + 32 + **Files modified:** 33 + - `src/routes/game/[did]/[rkey]/+page.svelte` -- added DM button in waiting state 34 + 35 + ### 1c. Color Selection -- DONE 36 + 37 + White/Black/Random segmented control on the `/play` form. Game page logic fully decoupled from record ownership: 38 + 39 + - Owner creates canonical record regardless of their color 40 + - `white`/`black` fields determine color, independent of who owns the record 41 + - Non-owner creates child record with `parentGameUri` regardless of their color 42 + - Jetstream, record pairing, and join logic all work for either color assignment 43 + - `createGame` now accepts optional `white` param (was required) 44 + 45 + **Files modified:** 46 + - `src/routes/play/+page.svelte` -- color selector UI, `resolveColors()` helper 47 + - `src/routes/game/[did]/[rkey]/+page.svelte` -- `loadGame()` rewritten for color-agnostic logic, `joinAsBlack` renamed to `joinGame`, `waitForOpponent` handles either color 48 + - `src/lib/atproto.ts` -- `white` param made optional in `createGame` 49 + - `src/lib/types.ts` -- `challengerColor` added to `ChallengeRecord` 50 + - `lexicons/blue.checkmate.challenge.json` -- `challengerColor` field added 51 + 52 + ### Also: Check-Before-Join Guard 53 + 54 + Before creating a child record to join a game, the game page re-fetches the owner's record to verify the slot is still empty. If it's been filled (race condition), the viewer falls into spectator mode instead of creating an orphaned record. 55 + 56 + --- 57 + 58 + ## What Was NOT Implemented 59 + 60 + - **Editable post text** -- the Bluesky post uses a fixed template. An editable textarea (planned for Phase 5's game result sharing) was deferred. 61 + - **Bluesky post text adjusting for color choice** -- the post always says "I'm challenging @handle to a game of chess." It doesn't mention which color the challenger picked. Could be added later. 62 + - **API-driven DM sending** -- requires `transition:chat.bsky` scope. Deferred unless there's a strong reason to broaden permissions. 63 + 64 + --- 65 + 66 + ## Decisions Made During Implementation 67 + 68 + - **OAuth scope is already minimal.** `atproto transition:generic` is the tightest scope available today. There are no collection-level scopes yet. The `transition:` prefix signals these are temporary and will be replaced by granular permissions eventually. 69 + - **DM deep linking doesn't work.** Bluesky message URLs use conversation IDs (`bsky.app/messages/{convoId}`), not DIDs. Resolving the conversation ID requires `chat.bsky` scope. The fallback (open inbox) is acceptable. 70 + - **Record ownership and color are now fully independent.** The game URL always uses the creator's DID/rkey. The `white`/`black` fields determine who plays what. This is a meaningful architectural change from the original design where owner=White was assumed.
+212
plans/phase-2-shareability-branding.md
··· 1 + # Phase 2: Shareability & Branding 2 + 3 + Priority: **High** 4 + 5 + ## Problem 6 + 7 + The app has no logo and no social card metadata. Shared links on Bluesky, Twitter, and Discord show bare URLs with no preview. This hurts discoverability and makes the platform look unfinished. 8 + 9 + ## Current State 10 + 11 + - `app.html` has a plain `<title>checkmate.blue</title>` and no OG meta tags. 12 + - `+layout.svelte` sets `<link rel="icon" href="/favicon.svg">`. 13 + - `static/favicon.svg` and `static/mascot-placeholder.svg` exist as placeholders. 14 + - The app is a pure SPA: SSR disabled, static adapter with `fallback: index.html`. Every route serves the same HTML shell. 15 + 16 + **Key files:** 17 + - `src/app.html` -- HTML shell 18 + - `src/routes/+layout.svelte` -- global layout, head tags 19 + - `static/` -- static assets 20 + 21 + --- 22 + 23 + ## 2a. Logo 24 + 25 + ### Overview 26 + 27 + A FontAwesome chess knight icon colored in AT Protocol blue (`#0085FF`) on the existing dark background (`#0f1419`). 28 + 29 + ### Implementation 30 + 31 + **Create `static/logo.svg`:** 32 + 33 + Extract the SVG path from FontAwesome's `chess-knight-piece` (solid variant, FA Free). The FA Free license (CC BY 4.0 for icons) allows use with attribution. Include a comment in the SVG referencing FontAwesome. 34 + 35 + The SVG should be: 36 + - Viewbox: `0 0 384 512` (standard FA dimensions for this icon) 37 + - Fill: `#0085FF` 38 + - No background (transparent) 39 + 40 + **Create `static/favicon.svg`** (replace existing): 41 + 42 + Same knight icon, sized for favicon use. SVG favicons scale naturally, so this can be the same file or a simplified version. 43 + 44 + **Create `static/og-default.png`:** 45 + 46 + A 1200x630 image (standard OG image dimensions) with: 47 + - Background: `#0f1419` (the app's `--bg-primary`) 48 + - Centered knight icon in `#0085FF` 49 + - "checkmate.blue" text below the icon in white 50 + - "Chess on the Atmosphere" subtitle in `#71767b` 51 + 52 + This can be generated once as a static asset. Use any image tool or a build script. 53 + 54 + **Create PNG variants for PWA (Phase 6e, but generate now):** 55 + 56 + - `static/icon-192.png` -- 192x192, knight on dark background 57 + - `static/icon-512.png` -- 512x512, knight on dark background 58 + - `static/apple-touch-icon.png` -- 180x180 59 + 60 + **Update `+layout.svelte`:** 61 + 62 + Replace the favicon reference. Add Apple touch icon. 63 + 64 + ```svelte 65 + <svelte:head> 66 + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 67 + <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> 68 + </svelte:head> 69 + ``` 70 + 71 + **Update navbar in `+layout.svelte`:** 72 + 73 + Replace the text-only "checkmate.blue" link with the logo icon + text: 74 + 75 + ```svelte 76 + <a href="/" class="flex items-center gap-2 text-lg font-bold text-accent-blue"> 77 + <img src="/logo.svg" alt="" class="h-6 w-6" /> 78 + checkmate.blue 79 + </a> 80 + ``` 81 + 82 + ### Acceptance Criteria 83 + 84 + - [ ] `static/logo.svg` exists with the knight icon in `#0085FF`. 85 + - [ ] `static/favicon.svg` replaced with the knight icon. 86 + - [ ] `static/og-default.png` exists at 1200x630 with logo + text on dark background. 87 + - [ ] Navbar shows the logo icon alongside the site name. 88 + - [ ] Favicon displays correctly in browser tabs. 89 + 90 + ### Edge Cases 91 + 92 + - SVG favicon support: all modern browsers support SVG favicons. No PNG fallback needed for the PoC, but the Apple touch icon covers iOS. 93 + 94 + --- 95 + 96 + ## 2b. Open Graph Meta Tags 97 + 98 + ### Overview 99 + 100 + Add meta tags so shared links show rich previews. Start with static/generic tags (same card for all routes), with a plan for dynamic per-route tags via a Cloudflare Worker later. 101 + 102 + ### Implementation 103 + 104 + #### Step 1: Static Meta Tags (Immediate) 105 + 106 + **Update `src/app.html`:** 107 + 108 + Add OG and Twitter Card meta tags to the `<head>`: 109 + 110 + ```html 111 + <meta property="og:type" content="website" /> 112 + <meta property="og:site_name" content="checkmate.blue" /> 113 + <meta property="og:title" content="checkmate.blue" /> 114 + <meta property="og:description" content="Chess on the Atmosphere -- federated chess on AT Protocol" /> 115 + <meta property="og:image" content="https://checkmate.blue/og-default.png" /> 116 + <meta property="og:url" content="https://checkmate.blue" /> 117 + <meta name="twitter:card" content="summary_large_image" /> 118 + <meta name="twitter:title" content="checkmate.blue" /> 119 + <meta name="twitter:description" content="Chess on the Atmosphere -- federated chess on AT Protocol" /> 120 + <meta name="twitter:image" content="https://checkmate.blue/og-default.png" /> 121 + ``` 122 + 123 + This gives every page the same generic card. Not ideal but functional. 124 + 125 + #### Step 2: Dynamic OG Tags via Cloudflare Worker (Fast Follow) 126 + 127 + **Separate deployment** -- a Cloudflare Worker sits in front of the static site and intercepts requests from known bot user agents. 128 + 129 + **Bot user agents to detect:** 130 + 131 + ``` 132 + Twitterbot, facebookexternalhit, LinkedInBot, Slackbot, Discordbot, 133 + WhatsApp, TelegramBot, Bluesky (cardyb) 134 + ``` 135 + 136 + **Worker logic:** 137 + 138 + 1. Check `User-Agent` header against bot list. 139 + 2. If not a bot, pass through to the static site (origin). 140 + 3. If a bot, parse the URL path: 141 + - `/game/{did}/{rkey}` -- fetch game record from PDS, extract player handles/status/move count, return HTML with dynamic OG tags. 142 + - `/challenge/{did}/{rkey}` -- fetch challenge record, return HTML with challenger info. 143 + - `/profile/{handle}` -- resolve handle, return HTML with profile info. 144 + - All other routes -- return generic OG tags. 145 + 4. Return a minimal HTML document with just the `<head>` containing OG tags. The body can be empty or contain a redirect meta tag -- bots only read the head. 146 + 147 + **Dynamic OG content per route:** 148 + 149 + | Route | og:title | og:description | og:image | 150 + |-------|----------|---------------|----------| 151 + | `/` | checkmate.blue | Chess on the Atmosphere | `/og-default.png` | 152 + | `/game/{did}/{rkey}` | {whiteHandle} vs {blackHandle} | {status} -- {moveCount} moves played | `/og-default.png` (or dynamic board image later) | 153 + | `/challenge/{did}/{rkey}` | Chess Challenge from {handle} | {handle} wants to play chess on checkmate.blue | `/og-default.png` | 154 + | `/profile/{handle}` | {handle} on checkmate.blue | Chess profile on checkmate.blue | `/og-default.png` | 155 + 156 + **Worker needs to:** 157 + - Resolve DIDs to handles via Slingshot or public API. 158 + - Fetch game records via public `getRecord` (no auth needed). 159 + - Cache responses (OG tags don't need to be real-time; 5-minute TTL is fine). 160 + 161 + **This is a separate deployment artifact** but can live in this repo under a `worker/` directory, or in its own repo. Decision left to implementation time. 162 + 163 + #### Step 3: Dynamic OG Images (Nice-to-Have) 164 + 165 + Generate per-game OG images showing the board position. This would be a separate service (Cloudflare Worker with `@cloudflare/pages-plugin-satori` or similar) that: 166 + 167 + 1. Receives a request like `/og/game/{did}/{rkey}.png`. 168 + 2. Fetches the game record, extracts the FEN from the PGN. 169 + 3. Renders a chess board as an SVG/PNG with player names, move count, and status. 170 + 4. Returns the image with cache headers. 171 + 172 + The Cloudflare Worker from Step 2 would reference this URL in the `og:image` tag for game routes. 173 + 174 + **Deferred** -- the static `og-default.png` is sufficient for launch. 175 + 176 + ### Acceptance Criteria 177 + 178 + #### Step 1 (Static) 179 + - [ ] `app.html` contains OG and Twitter Card meta tags. 180 + - [ ] Sharing any checkmate.blue URL on Bluesky/Twitter/Discord shows a card with the logo image, title, and description. 181 + 182 + #### Step 2 (Dynamic Worker) 183 + - [ ] Sharing a game URL shows the player handles and game status in the card. 184 + - [ ] Sharing a challenge URL shows the challenger's handle. 185 + - [ ] Non-bot requests pass through to the SPA with no change in behavior. 186 + - [ ] Worker responses are cached (5-minute TTL). 187 + 188 + ### Edge Cases 189 + 190 + - Bot user agent detection is imperfect. Some bots may not be caught (they get generic tags -- acceptable). Some real users may be misidentified as bots (they get the minimal HTML -- the meta refresh or JS redirect handles this). 191 + - Game records may not exist (deleted, invalid rkey) -- worker falls back to generic tags. 192 + - Handle resolution may fail -- use the DID as fallback text. 193 + 194 + --- 195 + 196 + ## Files to Create 197 + 198 + | File | Purpose | 199 + |------|---------| 200 + | `static/logo.svg` | Knight logo in AT Protocol blue | 201 + | `static/og-default.png` | Default OG card image (1200x630) | 202 + | `static/icon-192.png` | PWA icon | 203 + | `static/icon-512.png` | PWA icon | 204 + | `static/apple-touch-icon.png` | iOS home screen icon | 205 + 206 + ## Files to Modify 207 + 208 + | File | Changes | 209 + |------|---------| 210 + | `src/app.html` | Add OG and Twitter Card meta tags | 211 + | `src/routes/+layout.svelte` | Update favicon ref, add apple-touch-icon, add logo to navbar | 212 + | `static/favicon.svg` | Replace with knight logo |
+61
plans/phase-3-spectator-mode.md
··· 1 + # Phase 3: Spectator Mode 2 + 3 + Status: **Implemented** (branch: `phase-1-challenge-invite-flow`) 4 + 5 + Pulled forward and implemented alongside Phase 1 because spectator mode is the natural fallback when multiple people click a shared game link. 6 + 7 + --- 8 + 9 + ## What Was Implemented 10 + 11 + ### Read-Only Game View -- DONE 12 + 13 + Non-participants (logged in or not) see a fully functional spectator view: 14 + 15 + - Board rendered in view-only mode (`movable: false`, no legal move indicators) 16 + - "Spectating" label displayed 17 + - "Flip board" button toggles between White/Black perspective 18 + - GameControls (resign, draw offer) hidden 19 + - Player bars show handles with correct active-turn indicators based on board orientation 20 + - Game result displayed for completed games 21 + 22 + ### Unauthenticated Viewing -- DONE 23 + 24 + Non-logged-in users can view any game. The `$effect` that triggers `loadGame()` now fires after auth initialization completes regardless of login status. Record reads use public (unauthenticated) agents. 25 + 26 + **New helpers in `atproto.ts`:** 27 + - `getGamePublic(did, rkey)` -- reads a game record without authentication 28 + - `findGameRecordByParentPublic(did, parentUri)` -- finds child records without authentication 29 + 30 + ### Dual Jetstream Connections -- DONE 31 + 32 + Spectators subscribe to both players' DIDs via two separate `JetstreamConnection` instances. The `jsConnections` array (replacing the single `jsConnection`) tracks all connections and cleans them up on navigation. 33 + 34 + Connection status indicator shows "Live" when at least one connection is active. 35 + 36 + ### Game-Full Fallback -- DONE 37 + 38 + When a non-participant loads a game where both slots are filled, they're placed in spectator mode instead of seeing an error. This handles: 39 + - Multiple people clicking a publicly shared link 40 + - Someone visiting a game they're not part of 41 + - Direct link sharing for spectating 42 + 43 + ### Check-Before-Join -- DONE 44 + 45 + Before creating a child record to join, the game page re-fetches the owner's record to verify the slot is still open. If filled between the first fetch and the re-fetch, the viewer enters spectator mode. This reduces (but doesn't eliminate) orphaned records from race conditions. 46 + 47 + --- 48 + 49 + ## What Was NOT Implemented 50 + 51 + - **Spectator count** -- would need a server component. Out of scope. 52 + - **Partial connection indicator** -- the spec proposed a yellow "warning" state when one of two Jetstream connections drops. The implementation uses a simpler binary (connected if any connection is live). 53 + 54 + --- 55 + 56 + ## Files Modified 57 + 58 + | File | Changes | 59 + |------|---------| 60 + | `src/routes/game/[did]/[rkey]/+page.svelte` | `isSpectator` state, unauthenticated loading, `reconcileSpectator()`, `connectJetstreamSpectator()`, `destroyConnections()`, flip board, spectator UI | 61 + | `src/lib/atproto.ts` | `getGamePublic()`, `findGameRecordByParentPublic()` |
+291
plans/phase-4-homepage-game-discovery.md
··· 1 + # Phase 4: Homepage & Game Discovery 2 + 3 + Priority: **Medium** 4 + 5 + ## Problem 6 + 7 + The homepage only shows the logged-in user's own games as a list of opaque rkey links. There's no way to see what's happening on the platform, no way to discover active games to watch, and the game list provides no useful information at a glance. New visitors see nothing but a login prompt. 8 + 9 + ## Current State 10 + 11 + **Homepage (`src/routes/+page.svelte`):** 12 + 13 + - Logged in: shows "Your Games" fetched via `findGamesForPlayer(did)` from Constellation. Each game is displayed as a clickable rkey string (line 71) -- no player names, no status, no move count. 14 + - Not logged in: shows title + login form. No content for unauthenticated visitors. 15 + 16 + **Constellation query (`src/lib/microcosm.ts`):** 17 + 18 + `findGamesForPlayer(did)` queries `constellation.microcosm.blue/links/all?target={did}&collection=blue.checkmate.game`. This returns all game records across the network that reference the given DID (games where they appear as white or black). Returns `{ uri, collection, path, target }` -- just record URIs, no record content. 19 + 20 + **Deduplication problem:** Each game has two records (White's and Black's). Constellation returns both for any player. The homepage currently shows all of them, leading to duplicates. 21 + 22 + **Key files:** 23 + - `src/routes/+page.svelte` -- homepage 24 + - `src/lib/microcosm.ts` -- Constellation/Slingshot helpers 25 + - `src/lib/atproto.ts` -- record CRUD 26 + - `src/lib/types.ts` -- record types 27 + 28 + --- 29 + 30 + ## Lexicon Change: `lastMoveAt` 31 + 32 + Add a `lastMoveAt` field to the `blue.checkmate.game` lexicon. This is needed for sorting games by recent activity without parsing PGN. 33 + 34 + **In `lexicons/blue.checkmate.game.json`**, add to `properties`: 35 + 36 + ```json 37 + "lastMoveAt": { 38 + "type": "string", 39 + "format": "datetime", 40 + "description": "Timestamp of the most recent move" 41 + } 42 + ``` 43 + 44 + **In `src/lib/types.ts`**, add to `GameRecord`: 45 + 46 + ```typescript 47 + lastMoveAt?: string; 48 + ``` 49 + 50 + **Update `writeMove` in the game page** to include `lastMoveAt`: 51 + 52 + ```typescript 53 + await updateGame(auth.agent, myRkey, { 54 + pgn, 55 + lastMoveAt: new Date().toISOString(), 56 + status: game.result ? 'completed' : 'active', 57 + // ... existing fields 58 + }); 59 + ``` 60 + 61 + This is backward-compatible -- existing records without `lastMoveAt` still work; they just sort to the bottom or use `createdAt` as fallback. 62 + 63 + --- 64 + 65 + ## 4a. User's Active Games (Improved) 66 + 67 + ### Overview 68 + 69 + Replace the current bare rkey list with rich game cards showing player handles, game status, move count, and whose turn it is. 70 + 71 + ### Implementation 72 + 73 + **Fetch game records, not just URIs:** 74 + 75 + The current flow gets URIs from Constellation, then renders them directly. To show rich info, we need to fetch the actual game records. 76 + 77 + ```typescript 78 + async function loadGames(did: string) { 79 + const links = await findGamesForPlayer(did); 80 + 81 + // Deduplicate: only keep records where we are the owner (our DID in the URI) 82 + // OR where there is no parentGameUri (White's canonical record). 83 + // Since we can't filter without reading, fetch all and filter. 84 + const gamePromises = links.map(async (link) => { 85 + const parsed = parseAtUri(link.uri); 86 + if (!parsed) return null; 87 + const record = await getGamePublic(parsed.did, parsed.rkey); 88 + if (!record) return null; 89 + return { ...parsed, record }; 90 + }); 91 + 92 + const results = await Promise.all(gamePromises); 93 + 94 + // Deduplicate: if we have both White's and Black's record for the same game, 95 + // keep only the one WITHOUT parentGameUri (White's canonical record). 96 + const canonical = results.filter((g) => g && !g.record.parentGameUri); 97 + 98 + // Sort: active games first, then by lastMoveAt (or createdAt fallback), descending. 99 + games = canonical 100 + .sort((a, b) => { 101 + const aTime = a.record.lastMoveAt || a.record.createdAt; 102 + const bTime = b.record.lastMoveAt || b.record.createdAt; 103 + return bTime.localeCompare(aTime); 104 + }); 105 + } 106 + ``` 107 + 108 + **Performance concern:** Fetching N game records individually is slow. Mitigation: 109 + - Constellation typically returns a bounded number of results. 110 + - Use `Promise.all` for parallel fetches. 111 + - Consider adding Slingshot caching for records if available. 112 + - Limit to most recent 20 games initially. 113 + 114 + **Game card component: `src/lib/components/GameCard.svelte`** 115 + 116 + Displays a single game in the list: 117 + 118 + ``` 119 + ┌──────────────────────────────────────┐ 120 + │ whiteHandle vs blackHandle │ 121 + │ Active -- 24 moves -- White to move │ 122 + │ Last activity: 5 minutes ago │ 123 + └──────────────────────────────────────┘ 124 + ``` 125 + 126 + Props: 127 + - `game: { did: string, rkey: string, record: GameRecord }` 128 + 129 + The component resolves handles from DIDs using `resolveIdentity`. To avoid N+1 handle resolution, batch-resolve or cache results. 130 + 131 + **Handle caching:** 132 + 133 + Create a simple in-memory handle cache in `microcosm.ts`: 134 + 135 + ```typescript 136 + const handleCache = new Map<string, string>(); 137 + 138 + export async function resolveHandle(did: string): Promise<string> { 139 + if (handleCache.has(did)) return handleCache.get(did)!; 140 + const profile = await resolveIdentity(did); 141 + const handle = profile?.handle ?? did; 142 + handleCache.set(did, handle); 143 + return handle; 144 + } 145 + ``` 146 + 147 + **Status display:** 148 + - `waiting` -- "Waiting for opponent" 149 + - `active` -- "{turnColor} to move -- {moveCount} moves" 150 + - `completed` -- Result + reason (e.g., "White wins by checkmate") 151 + - `abandoned` -- "Abandoned" 152 + 153 + **Relative time display:** 154 + 155 + Use a simple relative time formatter (no library needed): 156 + 157 + ```typescript 158 + function timeAgo(iso: string): string { 159 + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 160 + if (seconds < 60) return 'just now'; 161 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 162 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 163 + return `${Math.floor(seconds / 86400)}d ago`; 164 + } 165 + ``` 166 + 167 + ### Homepage Layout Update 168 + 169 + ```svelte 170 + {#if auth.isLoggedIn} 171 + <!-- Existing: signed-in header with New Game + Sign Out --> 172 + 173 + {#if loadingGames} 174 + <p>Loading games...</p> 175 + {:else} 176 + {#if activeGames.length > 0} 177 + <section> 178 + <h2>Your Active Games</h2> 179 + {#each activeGames as game} 180 + <GameCard {game} /> 181 + {/each} 182 + </section> 183 + {/if} 184 + 185 + {#if completedGames.length > 0} 186 + <section> 187 + <h2>Completed</h2> 188 + {#each completedGames.slice(0, 5) as game} 189 + <GameCard {game} /> 190 + {/each} 191 + </section> 192 + {/if} 193 + {/if} 194 + {/if} 195 + ``` 196 + 197 + Split games into `activeGames` (status `waiting` or `active`) and `completedGames` (status `completed`). 198 + 199 + --- 200 + 201 + ## 4b. Global Active Games Feed 202 + 203 + ### Overview 204 + 205 + Show active games from all players on the homepage, visible to everyone (including non-logged-in visitors). This makes the platform feel alive. 206 + 207 + ### Discovery Challenge 208 + 209 + Constellation queries require a target DID -- there's no "give me all records of this collection" global query. This means we can't easily discover games from unknown players. 210 + 211 + **Options:** 212 + 213 + 1. **Jetstream client-side indexing:** Connect to Jetstream filtered on `blue.checkmate.game` (no DID filter) and build a local index of active games. Shows only games with activity since the page loaded. Ephemeral -- lost on refresh. 214 + 215 + 2. **Known-players seed list:** Maintain a small list of known active players (hardcoded or fetched from a config). Query each player's games via Constellation. Doesn't scale, but works for early days when the player base is small. 216 + 217 + 3. **Relay/indexer service (separate project):** A server-side Jetstream listener that indexes all `blue.checkmate.game` records and exposes an API. This is the proper solution but breaks the "no server" constraint for the main app. Could be bundled with the bot project. 218 + 219 + 4. **Constellation global query:** Investigate if Constellation supports querying by collection without a target DID. If `target` is optional, `collection=blue.checkmate.game` might return all game records. 220 + 221 + **Recommended approach:** Start with option 1 (Jetstream client-side) for the "Live Games" section, and option 2 (seed list) for a "Recent Games" section. Plan for option 3 as part of the bot/indexer project. 222 + 223 + ### Jetstream Live Feed Implementation 224 + 225 + Connect to Jetstream on the homepage with `wantedCollections=blue.checkmate.game` (no DID filter). As game updates arrive, build a local map of active games: 226 + 227 + ```typescript 228 + const liveGames = new Map<string, { did: string, rkey: string, record: GameRecord }>(); 229 + 230 + function handleJetstreamEvent(event: JetstreamEvent) { 231 + if (event.commit.operation === 'update' || event.commit.operation === 'create') { 232 + const record = event.commit.record as GameRecord; 233 + // Only track canonical records (no parentGameUri) 234 + if (!record.parentGameUri && record.status === 'active') { 235 + const key = `${event.did}/${event.commit.rkey}`; 236 + liveGames.set(key, { did: event.did, rkey: event.commit.rkey, record }); 237 + } 238 + } 239 + } 240 + ``` 241 + 242 + Display these as a "Live Games" section on the homepage. Sort by most recent update. Show a note: "Showing games with activity since you opened this page." 243 + 244 + **Considerations:** 245 + - Without a DID filter, Jetstream sends all `blue.checkmate.game` events. At small scale this is fine. At large scale it could be noisy -- but by that point you'd want the indexer (option 3). 246 + - Rate limit the UI updates (debounce or batch every second). 247 + - Disconnect the Jetstream connection when the user navigates away from the homepage. 248 + 249 + --- 250 + 251 + ## Acceptance Criteria 252 + 253 + ### 4a (User's Games) 254 + - [ ] Homepage shows the user's active games as rich cards with player handles, status, move count, and last activity. 255 + - [ ] Games are deduplicated (only canonical/White's records shown). 256 + - [ ] Games are sorted by most recent activity. 257 + - [ ] Completed games shown in a separate section. 258 + - [ ] Game cards link to the game page. 259 + 260 + ### 4b (Global Feed) 261 + - [ ] Non-logged-in visitors see a "Live Games" section showing active games discovered via Jetstream. 262 + - [ ] Games update in real-time as moves are made. 263 + - [ ] Only canonical records (no `parentGameUri`) are shown. 264 + - [ ] Jetstream connection is cleaned up on navigation away from homepage. 265 + 266 + ## Edge Cases 267 + 268 + - Player has no games: show "No games yet" with a prompt to create one. 269 + - Game record fetch fails (PDS down): skip that game, don't break the list. 270 + - Many games (>20): paginate or limit to most recent 20 with a "Show more" option. 271 + - `lastMoveAt` not set on old records: fall back to `createdAt` for sorting. 272 + - Constellation returns stale data (deleted records): gracefully handle 404s on individual record fetches. 273 + 274 + --- 275 + 276 + ## Files to Create 277 + 278 + | File | Purpose | 279 + |------|---------| 280 + | `src/lib/components/GameCard.svelte` | Rich game card for lists | 281 + 282 + ## Files to Modify 283 + 284 + | File | Changes | 285 + |------|---------| 286 + | `src/routes/+page.svelte` | Redesigned homepage with rich game lists, Jetstream live feed | 287 + | `src/lib/microcosm.ts` | Add handle caching | 288 + | `src/lib/types.ts` | Add `lastMoveAt` to `GameRecord` | 289 + | `src/lib/atproto.ts` | Add `getGamePublic` if not already added in Phase 3 | 290 + | `src/routes/game/[did]/[rkey]/+page.svelte` | Include `lastMoveAt` in `writeMove` calls | 291 + | `lexicons/blue.checkmate.game.json` | Add `lastMoveAt` field |
+231
plans/phase-5-game-result-sharing.md
··· 1 + # Phase 5: Game Result Sharing 2 + 3 + Priority: **Medium** 4 + 5 + ## Problem 6 + 7 + When a game ends, there's no way to share the result on Bluesky. This is the viral loop: players share their wins (and good games) to their followers, who discover checkmate.blue and start playing. 8 + 9 + ## Current State 10 + 11 + **Game completion flow (`src/routes/game/[did]/[rkey]/+page.svelte`):** 12 + 13 + When a game ends (checkmate, stalemate, resignation, draw), the result block shows: 14 + - "White wins" / "Black wins" / "Draw" 15 + - The result reason (e.g., "checkmate") 16 + 17 + There's no sharing option. The game page shows the result and that's it. 18 + 19 + **Reusable infrastructure from Phase 1:** `src/lib/bluesky.ts` already provides `buildFacets()` (auto-detects @mentions and URLs with correct UTF-8 byte offsets) and `postToBluesky()` (creates a Bluesky post with facets and optional embed). This phase adds `composeGameResultPost()` and the editable textarea UI. 20 + 21 + **Key files:** 22 + - `src/routes/game/[did]/[rkey]/+page.svelte` -- game page, result display 23 + - `src/lib/bluesky.ts` -- Bluesky post helpers (created in Phase 1) 24 + - `src/lib/stores/game.svelte.ts` -- game state, result detection 25 + - `src/lib/game-logic.ts` -- `gameResult()` function 26 + 27 + --- 28 + 29 + ## Implementation 30 + 31 + ### Post Content 32 + 33 + The post should be: 34 + - Brief and natural-sounding 35 + - Include the result and reason 36 + - Mention the opponent (so they see it in notifications) 37 + - Link to the game (so followers can view it via spectator mode) 38 + 39 + **Template variations based on result:** 40 + 41 + ``` 42 + Win by checkmate: "Checkmate! I won against @{opponent} on checkmate.blue {gameUrl}" 43 + Win by resignation: "Victory! @{opponent} resigned our game on checkmate.blue {gameUrl}" 44 + Loss by checkmate: "Got checkmated by @{opponent} on checkmate.blue -- good game! {gameUrl}" 45 + Loss by resignation: (player resigned -- they may not want to broadcast this. Still offer the option.) 46 + Draw (stalemate): "Stalemate with @{opponent} on checkmate.blue {gameUrl}" 47 + Draw (agreement): "Agreed to a draw with @{opponent} on checkmate.blue {gameUrl}" 48 + Draw (repetition): "Draw by repetition with @{opponent} on checkmate.blue {gameUrl}" 49 + Draw (50 moves): "Draw by 50-move rule with @{opponent} on checkmate.blue {gameUrl}" 50 + Draw (insufficient): "Draw -- insufficient material -- with @{opponent} on checkmate.blue {gameUrl}" 51 + ``` 52 + 53 + **Include move count** for flavor: "...in 47 moves" appended when > 10 moves. 54 + 55 + ### Editable Post 56 + 57 + Show the pre-filled text in an editable textarea so the player can customize before posting. This is important -- auto-generated text feels impersonal, and players may want to add their own commentary. 58 + 59 + ### New Function in `src/lib/bluesky.ts` 60 + 61 + ```typescript 62 + export function composeGameResultPost( 63 + myColor: 'white' | 'black', 64 + result: { result: '1-0' | '0-1' | '1/2-1/2'; reason: string }, 65 + opponentHandle: string, 66 + moveCount: number, 67 + ): string 68 + ``` 69 + 70 + Returns the default post text. Separate from the posting logic so the text can be displayed in the textarea first. 71 + 72 + ```typescript 73 + export async function postToBluesky( 74 + agent: Agent, 75 + text: string, 76 + mentionDid?: string, 77 + mentionHandle?: string, 78 + linkUrl?: string, 79 + ): Promise<{ uri: string; cid: string }> 80 + ``` 81 + 82 + Generalized posting function (also usable by Phase 1's challenge posts). Constructs facets for any mentions and links found in the text. 83 + 84 + **Facet detection:** 85 + 86 + Rather than building facets from parameters, scan the text for `@handle` patterns and URL patterns, then construct facets with correct byte offsets. This way the user can edit the text (move the mention around, add more text) and facets are always correct. 87 + 88 + ```typescript 89 + function detectFacets(text: string, knownHandles: Map<string, string>): Facet[] { 90 + // Find @mentions -- match against knownHandles map to get DIDs 91 + // Find URLs -- https://... patterns 92 + // Return facet array with correct byte offsets 93 + } 94 + ``` 95 + 96 + The `knownHandles` map lets us resolve `@handle` to a DID for the mention facet without an API call at post time. 97 + 98 + ### UI: Share Button on Game Page 99 + 100 + After the result block (lines 349-362 in the game page), add a "Share to Bluesky" section: 101 + 102 + ```svelte 103 + {#if game.result && !isSpectator} 104 + <div class="result-block"> 105 + <!-- existing result display --> 106 + </div> 107 + 108 + {#if !shared} 109 + <div class="share-section"> 110 + <textarea 111 + bind:value={shareText} 112 + rows="3" 113 + class="..." 114 + maxlength="300" 115 + ></textarea> 116 + <div class="flex gap-2"> 117 + <button onclick={shareResult} disabled={sharing}> 118 + {sharing ? 'Posting...' : 'Share to Bluesky'} 119 + </button> 120 + <button onclick={() => dismissShare = true}> 121 + Dismiss 122 + </button> 123 + </div> 124 + <p class="character-count">{shareText.length}/300</p> 125 + </div> 126 + {:else} 127 + <p>Shared!</p> 128 + {/if} 129 + {/if} 130 + ``` 131 + 132 + **State variables:** 133 + 134 + ```typescript 135 + let shareText = $state(''); 136 + let sharing = $state(false); 137 + let shared = $state(false); 138 + let dismissShare = $state(false); 139 + ``` 140 + 141 + **Initialize `shareText`** when the game result is detected: 142 + 143 + ```typescript 144 + $effect(() => { 145 + if (game.result && !shareText) { 146 + shareText = composeGameResultPost( 147 + game.myColor, 148 + game.result, 149 + opponentHandle ?? 'opponent', 150 + game.moveCount, 151 + ); 152 + } 153 + }); 154 + ``` 155 + 156 + ### Post Creation 157 + 158 + ```typescript 159 + async function shareResult() { 160 + if (!auth.agent || !shareText.trim()) return; 161 + sharing = true; 162 + 163 + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 164 + const gameUrl = `https://checkmate.blue/game/${ownerDid}/${rkey}`; 165 + 166 + // Build known handles map for facet detection 167 + const handles = new Map<string, string>(); 168 + if (opponentDid && opponentHandle) { 169 + handles.set(opponentHandle, opponentDid); 170 + } 171 + 172 + await postToBluesky(auth.agent, shareText, handles, gameUrl); 173 + shared = true; 174 + sharing = false; 175 + } 176 + ``` 177 + 178 + ### Link Card Embed 179 + 180 + The post should include an `app.bsky.embed.external` embed with the game URL: 181 + 182 + ```typescript 183 + embed: { 184 + $type: 'app.bsky.embed.external', 185 + external: { 186 + uri: gameUrl, 187 + title: `${whiteHandle} vs ${blackHandle}`, 188 + description: `${result.result} by ${result.reason} -- ${moveCount} moves`, 189 + }, 190 + } 191 + ``` 192 + 193 + If Phase 2's OG tags are implemented, Bluesky's card fetcher will use those instead. The embed `title`/`description` are fallbacks. 194 + 195 + --- 196 + 197 + ## Acceptance Criteria 198 + 199 + - [ ] After a game ends, a "Share to Bluesky" section appears below the result for participants. 200 + - [ ] The share section contains an editable textarea pre-filled with a natural result message. 201 + - [ ] The message mentions the opponent's handle. 202 + - [ ] Clicking "Share to Bluesky" creates a post in the player's Bluesky feed. 203 + - [ ] The post has correct facets: clickable mention for the opponent, clickable link for the game URL. 204 + - [ ] The post includes a link card embed with game info. 205 + - [ ] Character count is shown (Bluesky limit: 300 graphemes). 206 + - [ ] The textarea prevents input beyond 300 characters. 207 + - [ ] A "Dismiss" option hides the share section without posting. 208 + - [ ] After posting, the section shows "Shared!" confirmation. 209 + - [ ] Spectators do not see the share section. 210 + - [ ] Errors during posting show a message and allow retry. 211 + 212 + ## Edge Cases 213 + 214 + - Opponent handle not yet resolved when game ends: use DID as fallback in the default text. If handle resolves later, don't overwrite user's edits. 215 + - Player edits the text to remove the @mention: post still works, just without the mention facet. 216 + - Player edits the text to add additional @mentions: the `detectFacets` approach handles this if we resolve the handle. For unknown handles, skip the mention facet (it renders as plain text). 217 + - Game ended by resignation: the resigning player may not want to share. The share section still appears (it's opt-in) but the default text is neutral. 218 + - Text exceeds 300 graphemes: disable the post button, show count in red. Note: grapheme count != string length for emoji/unicode. Use `Intl.Segmenter` for accurate grapheme counting. 219 + 220 + --- 221 + 222 + ## Files to Create 223 + 224 + None (Phase 1 creates `src/lib/bluesky.ts`). 225 + 226 + ## Files to Modify 227 + 228 + | File | Changes | 229 + |------|---------| 230 + | `src/lib/bluesky.ts` | Add `composeGameResultPost`, generalize `postToBluesky` with facet detection | 231 + | `src/routes/game/[did]/[rkey]/+page.svelte` | Add share section after result block |
+313
plans/phase-6-polish.md
··· 1 + # Phase 6: Polish 2 + 3 + Priority: **Lower** 4 + 5 + Quality-of-life improvements. Each sub-phase is independent and can be done in any order or interleaved with other work. 6 + 7 + --- 8 + 9 + ## 6a. Sound Effects 10 + 11 + ### Problem 12 + 13 + The game is silent. No audio feedback on moves, captures, or game events. This makes gameplay feel disconnected, especially when waiting for an opponent's move. 14 + 15 + ### Implementation 16 + 17 + **Sound set:** 18 + 19 + Lichess sounds are BSD-licensed. Use the standard set: 20 + - `move.mp3` -- piece placement 21 + - `capture.mp3` -- capture 22 + - `check.mp3` -- move that gives check (optional, some players find this annoying) 23 + - `game-end.mp3` -- checkmate, stalemate, resignation, draw 24 + - `notify.mp3` -- opponent made a move (plays when it's your turn) 25 + 26 + Place in `static/sounds/`. 27 + 28 + **Audio playback helper: `src/lib/sounds.ts`** 29 + 30 + ```typescript 31 + const sounds = { 32 + move: () => new Audio('/sounds/move.mp3'), 33 + capture: () => new Audio('/sounds/capture.mp3'), 34 + gameEnd: () => new Audio('/sounds/game-end.mp3'), 35 + notify: () => new Audio('/sounds/notify.mp3'), 36 + }; 37 + 38 + export function playSound(type: keyof typeof sounds): void { 39 + try { 40 + sounds[type]().play(); 41 + } catch { 42 + // Browser may block autoplay; ignore silently 43 + } 44 + } 45 + ``` 46 + 47 + Preload sounds on first user interaction to avoid autoplay restrictions. 48 + 49 + **Integration points:** 50 + 51 + - `handleMove()` in the game page: after a successful move, play `move` or `capture` based on whether the move was a capture. chess.js move result includes a `captured` field. 52 + - `applyOpponentMove()`: play `notify` when the opponent moves. 53 + - Game over detection: play `gameEnd`. 54 + 55 + **Detecting captures:** The current `tryMove` and `applyMove` functions in `game-logic.ts` return a boolean. To know if a move was a capture, either: 56 + - Change `applyMove` to return the move object (which has a `captured` field), or 57 + - Check if a piece was on the destination square before the move. 58 + 59 + Simplest: change `applyMove` to return `Move | null` instead of `boolean`, and update callers. 60 + 61 + **Mute toggle:** Add a sound toggle in the nav or game page. Store preference in `localStorage`. 62 + 63 + ### Acceptance Criteria 64 + 65 + - [ ] Piece moves play a sound. 66 + - [ ] Captures play a distinct sound. 67 + - [ ] Game-ending events play a sound. 68 + - [ ] Opponent's move triggers a notification sound. 69 + - [ ] Sound preference persists in localStorage. 70 + - [ ] Sound toggle is accessible from the game page. 71 + - [ ] No errors if the browser blocks autoplay. 72 + 73 + --- 74 + 75 + ## 6b. Rematch Button 76 + 77 + ### Problem 78 + 79 + After a game ends, starting a new game with the same opponent requires navigating to `/play`, entering their handle again, and sharing a new link. A one-click rematch with swapped colors is the expected UX. 80 + 81 + ### Implementation 82 + 83 + **UI:** After game result display, add a "Rematch" button alongside the share section (Phase 5). 84 + 85 + ```svelte 86 + {#if game.result && !isSpectator} 87 + <!-- result display --> 88 + <!-- share section --> 89 + <button onclick={handleRematch} disabled={rematchCreating}> 90 + {rematchCreating ? 'Creating...' : 'Rematch (swap colors)'} 91 + </button> 92 + {/if} 93 + ``` 94 + 95 + **Logic:** 96 + 97 + ```typescript 98 + async function handleRematch() { 99 + if (!auth.agent || !auth.did) return; 100 + rematchCreating = true; 101 + 102 + // Swap colors 103 + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 104 + const newWhite = game.myColor === 'white' ? opponentDid : auth.did; 105 + const newBlack = game.myColor === 'white' ? auth.did : opponentDid; 106 + 107 + const result = await createGame(auth.agent, { 108 + white: newWhite, 109 + black: newBlack, 110 + status: 'waiting', 111 + }); 112 + 113 + goto(`/game/${auth.did}/${result.rkey}`); 114 + } 115 + ``` 116 + 117 + The rematch creates a new game in the current player's repo. The opponent needs to visit the link and join. The "Post to Bluesky" and copy-link UI on the waiting screen handles the invite (Phase 1). 118 + 119 + **Linking rematches:** Could add a `rematchOf` field to the game record pointing to the previous game URI. Nice for history but not essential. Defer unless there's a clear use case. 120 + 121 + ### Acceptance Criteria 122 + 123 + - [ ] "Rematch" button appears after game completion for participants. 124 + - [ ] Clicking it creates a new game with colors swapped. 125 + - [ ] Player is redirected to the new game's waiting screen. 126 + - [ ] The opponent can join via the same invite mechanisms (link, post, DM). 127 + 128 + --- 129 + 130 + ## 6c. PGN Export & Analysis 131 + 132 + ### Problem 133 + 134 + Players can't download their game's PGN or analyze it on external tools. 135 + 136 + ### Implementation 137 + 138 + **Download PGN button:** 139 + 140 + Add to the game page (visible for any game, in progress or completed): 141 + 142 + ```typescript 143 + function downloadPgn() { 144 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 145 + const blob = new Blob([pgn], { type: 'application/x-chess-pgn' }); 146 + const url = URL.createObjectURL(blob); 147 + const a = document.createElement('a'); 148 + a.href = url; 149 + a.download = `checkmate-blue-${rkey}.pgn`; 150 + a.click(); 151 + URL.revokeObjectURL(url); 152 + } 153 + ``` 154 + 155 + **Analyze on Lichess button:** 156 + 157 + Lichess accepts PGN via URL for analysis. The import endpoint accepts POST requests with PGN data, but for simplicity use the paste-friendly URL approach: 158 + 159 + ```typescript 160 + function openLichessAnalysis() { 161 + const pgn = encodeURIComponent(game.chess.pgn()); 162 + window.open(`https://lichess.org/paste?pgn=${pgn}`, '_blank'); 163 + } 164 + ``` 165 + 166 + Note: very long PGNs may exceed URL length limits. For games > ~100 moves, fall back to opening the Lichess import page and letting the user paste. 167 + 168 + Alternative: use the Lichess API to import the game: 169 + 170 + ```typescript 171 + const response = await fetch('https://lichess.org/api/import', { 172 + method: 'POST', 173 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 174 + body: `pgn=${encodeURIComponent(game.chess.pgn())}`, 175 + }); 176 + const data = await response.json(); 177 + window.open(data.url, '_blank'); 178 + ``` 179 + 180 + This returns a Lichess URL for the imported game with full analysis board. No Lichess auth required for public imports. 181 + 182 + ### Acceptance Criteria 183 + 184 + - [ ] "Download PGN" button available on all game pages. 185 + - [ ] Downloaded file has correct PGN content with headers. 186 + - [ ] "Analyze on Lichess" button opens Lichess with the game loaded. 187 + - [ ] Both buttons work for in-progress and completed games. 188 + 189 + --- 190 + 191 + ## 6d. Abandoned Game Detection 192 + 193 + ### Problem 194 + 195 + Games where one player stops responding have no resolution. The waiting player is stuck. 196 + 197 + ### Implementation 198 + 199 + **Client-side convention:** If `lastMoveAt` (Phase 4) is older than a threshold (e.g., 7 days), show an "Abandon" option to the active player. 200 + 201 + ```svelte 202 + {#if canAbandon} 203 + <button onclick={handleAbandon} class="text-sm text-text-secondary"> 204 + Claim win (opponent inactive {daysSinceLastMove} days) 205 + </button> 206 + {/if} 207 + ``` 208 + 209 + ```typescript 210 + const canAbandon = $derived(() => { 211 + if (game.status !== 'active' || !game.isMyTurn === false) return false; 212 + const lastMove = record?.lastMoveAt || record?.createdAt; 213 + if (!lastMove) return false; 214 + const daysSince = (Date.now() - new Date(lastMove).getTime()) / 86400000; 215 + return daysSince > 7; 216 + }); 217 + ``` 218 + 219 + **On abandon:** 220 + 221 + ```typescript 222 + async function handleAbandon() { 223 + const result = game.myColor === 'white' ? '1-0' : '0-1'; 224 + await updateGame(auth.agent, myRkey, { 225 + status: 'abandoned', 226 + result, 227 + resultReason: 'abandonment', 228 + }); 229 + game.setStatus('completed'); 230 + } 231 + ``` 232 + 233 + Note: `abandonment` is not in the current `resultReason` known values. Add it to the lexicon: 234 + 235 + ```json 236 + "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", 237 + "insufficient", "repetition", "fifty_moves", "abandonment"] 238 + ``` 239 + 240 + **No server-side enforcement.** This is a client-side convention. A malicious client could ignore it. The bot project (separate) could handle automated abandonment detection with more authority. 241 + 242 + ### Acceptance Criteria 243 + 244 + - [ ] After 7 days of inactivity, the waiting player sees an "Abandon" option. 245 + - [ ] Clicking it marks the game as abandoned with the inactive player losing. 246 + - [ ] The threshold is measured from `lastMoveAt` or `createdAt`. 247 + - [ ] Games in `waiting` status (opponent never joined) can also be abandoned/cancelled by the creator. 248 + 249 + --- 250 + 251 + ## 6e. Mobile PWA 252 + 253 + ### Problem 254 + 255 + The app works in mobile browsers but doesn't feel native. No home screen icon, no "Add to Home Screen" prompt, no offline shell. 256 + 257 + ### Implementation 258 + 259 + **Create `static/manifest.json`:** 260 + 261 + ```json 262 + { 263 + "name": "checkmate.blue", 264 + "short_name": "checkmate", 265 + "start_url": "/", 266 + "display": "standalone", 267 + "background_color": "#0f1419", 268 + "theme_color": "#0085FF", 269 + "icons": [ 270 + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, 271 + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } 272 + ] 273 + } 274 + ``` 275 + 276 + **Add manifest link to `app.html`:** 277 + 278 + ```html 279 + <link rel="manifest" href="/manifest.json" /> 280 + <meta name="theme-color" content="#0085FF" /> 281 + ``` 282 + 283 + **Service worker (optional):** SvelteKit can generate a service worker, but for a PoC the manifest alone provides the "Add to Home Screen" experience. A service worker for offline shell caching is a nice-to-have but not essential since the app requires network access for all core functionality. 284 + 285 + ### Acceptance Criteria 286 + 287 + - [ ] `manifest.json` exists with correct app name, icons, colors. 288 + - [ ] Mobile browsers show "Add to Home Screen" prompt. 289 + - [ ] App launched from home screen uses standalone display mode (no browser chrome). 290 + - [ ] Theme color matches the app's accent blue. 291 + 292 + --- 293 + 294 + ## Files to Create 295 + 296 + | File | Purpose | 297 + |------|---------| 298 + | `src/lib/sounds.ts` | Audio playback helper | 299 + | `static/sounds/move.mp3` | Move sound effect | 300 + | `static/sounds/capture.mp3` | Capture sound effect | 301 + | `static/sounds/game-end.mp3` | Game over sound | 302 + | `static/sounds/notify.mp3` | Opponent move notification | 303 + | `static/manifest.json` | PWA manifest | 304 + 305 + ## Files to Modify 306 + 307 + | File | Changes | 308 + |------|---------| 309 + | `src/routes/game/[did]/[rkey]/+page.svelte` | Rematch button, PGN export buttons, abandon option, sound integration | 310 + | `src/lib/game-logic.ts` | Change `applyMove` return type to `Move \| null` for capture detection | 311 + | `src/lib/types.ts` | Add `abandonment` to resultReason union | 312 + | `lexicons/blue.checkmate.game.json` | Add `abandonment` to resultReason knownValues | 313 + | `src/app.html` | Add manifest link, theme-color meta |
+98
plans/phase-7-open-challenge-board.md
··· 1 + # Phase 7: Open Challenge Board 2 + 3 + Priority: **Deferred** 4 + 5 + ## Problem 6 + 7 + Players can only challenge specific opponents by handle. There's no way to put out an open challenge for anyone to accept, and no way to browse available opponents. 8 + 9 + ## Current State 10 + 11 + The `/play` page requires entering an opponent handle (or leaving it blank for an "open" game that no one can discover). Challenges exist as `blue.checkmate.challenge` records but are only accessible via direct link. 12 + 13 + --- 14 + 15 + ## Prerequisite Investigation 16 + 17 + **Before implementing, determine whether Constellation supports global collection queries.** 18 + 19 + The current Constellation query pattern is: 20 + ``` 21 + GET /links/all?target={did}&collection=blue.checkmate.game 22 + ``` 23 + 24 + This returns records that reference a specific DID. For an open challenge board, we need records of a collection regardless of target: 25 + ``` 26 + GET /links/all?collection=blue.checkmate.challenge 27 + ``` 28 + 29 + If this is not supported, alternatives: 30 + - Use Jetstream to build a client-side index of open challenges (ephemeral, only shows challenges created since page load). 31 + - Build a lightweight indexer (part of the bot project) that maintains a list of open challenges. 32 + - Use a "challenge hub" pattern where open challenges reference a well-known DID (e.g., the checkmate.blue bot account), making them discoverable via Constellation. 33 + 34 + --- 35 + 36 + ## Implementation (Contingent on Discovery Mechanism) 37 + 38 + ### Challenge Creation 39 + 40 + Update `/play` to allow creating open challenges (no opponent specified). The game record is created with `status: waiting` and no `black` field. The challenge record has no `opponent` field. 41 + 42 + This already works mechanically -- the code just doesn't distinguish between "open" and "targeted" challenges in the UI. 43 + 44 + ### Challenge Board UI 45 + 46 + Add a section to the homepage (or a dedicated `/challenges` page): 47 + 48 + ``` 49 + ┌──────────────────────────────────────────┐ 50 + │ Open Challenges │ 51 + │ │ 52 + │ @alice.bsky.social -- White -- 2m ago │ 53 + │ [Accept] │ 54 + │ │ 55 + │ @bob.example.com -- Random -- 5m ago │ 56 + │ [Accept] │ 57 + │ │ 58 + │ (no more open challenges) │ 59 + └──────────────────────────────────────────┘ 60 + ``` 61 + 62 + Each challenge shows: 63 + - Challenger handle 64 + - Color preference (if Phase 1c is implemented) 65 + - Time since creation 66 + - "Accept" button 67 + 68 + ### Accepting an Open Challenge 69 + 70 + Same flow as accepting a targeted challenge: 71 + 1. Create Black's game record with `parentGameUri`. 72 + 2. Redirect to the game page. 73 + 74 + ### Challenge Expiry 75 + 76 + Open challenges should expire after a reasonable time (e.g., 1 hour). Since there's no server to enforce this: 77 + - The UI hides challenges older than the threshold. 78 + - The challenger's client can update `status: expired` if they revisit. 79 + - Stale challenges that someone tries to accept may result in a game the challenger never responds to -- handled by abandoned game detection (Phase 6d). 80 + 81 + ### Acceptance Criteria 82 + 83 + - [ ] Players can create open challenges (no specific opponent). 84 + - [ ] Open challenges are discoverable on the homepage or a dedicated page. 85 + - [ ] Challenges show the creator's handle, color preference, and age. 86 + - [ ] Any logged-in player can accept an open challenge. 87 + - [ ] Challenges older than 1 hour are hidden. 88 + - [ ] Accepting an open challenge follows the standard game join flow. 89 + 90 + --- 91 + 92 + ## Files to Modify 93 + 94 + | File | Changes | 95 + |------|---------| 96 + | `src/routes/+page.svelte` | Add open challenges section | 97 + | `src/routes/play/+page.svelte` | Distinguish open vs targeted challenge creation in UI | 98 + | `src/lib/microcosm.ts` | Add challenge discovery query (depends on mechanism) |
+2
src/app.html
··· 3 3 <head> 4 4 <meta charset="utf-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="theme-color" content="#1d9bf0" /> 7 + <link rel="manifest" href="/manifest.json" /> 6 8 <title>checkmate.blue</title> 7 9 %sveltekit.head% 8 10 </head>
+34
src/lib/stores/sound.svelte.ts
··· 1 + let muted = $state( 2 + typeof localStorage !== 'undefined' && localStorage.getItem('sound-muted') === 'true' 3 + ); 4 + 5 + const SOUNDS = { 6 + move: '/sounds/move.mp3', 7 + capture: '/sounds/capture.mp3', 8 + gameEnd: '/sounds/notify.mp3', 9 + notify: '/sounds/notify.mp3', 10 + } as const; 11 + 12 + type SoundType = keyof typeof SOUNDS; 13 + const cache = new Map<string, HTMLAudioElement>(); 14 + 15 + export const sound = { 16 + get muted() { return muted; }, 17 + 18 + toggle() { 19 + muted = !muted; 20 + localStorage.setItem('sound-muted', String(muted)); 21 + }, 22 + 23 + play(type: SoundType) { 24 + if (muted) return; 25 + const path = SOUNDS[type]; 26 + let el = cache.get(path); 27 + if (!el) { 28 + el = new Audio(path); 29 + cache.set(path, el); 30 + } 31 + el.currentTime = 0; 32 + el.play().catch(() => {}); 33 + }, 34 + };
+2 -1
src/lib/types.ts
··· 14 14 black?: string; 15 15 status: 'waiting' | 'active' | 'completed' | 'abandoned'; 16 16 result?: '1-0' | '0-1' | '1/2-1/2'; 17 - resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves'; 17 + resultReason?: 'checkmate' | 'resignation' | 'agreement' | 'stalemate' | 'insufficient' | 'repetition' | 'fifty_moves' | 'abandonment'; 18 + lastMoveAt?: string; 18 19 parentGameUri?: string; 19 20 drawOffered?: boolean; 20 21 timeControl?: TimeControl;
+174 -8
src/routes/game/[did]/[rkey]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from '$app/stores'; 3 + import { goto } from '$app/navigation'; 3 4 import { onMount } from 'svelte'; 4 5 import Board from '$lib/components/Board.svelte'; 5 6 import PlayerBar from '$lib/components/PlayerBar.svelte'; ··· 8 9 import PromotionModal from '$lib/components/PromotionModal.svelte'; 9 10 import { game } from '$lib/stores/game.svelte'; 10 11 import { auth } from '$lib/stores/auth.svelte'; 12 + import { sound } from '$lib/stores/sound.svelte'; 11 13 import { 12 14 getGame, getGamePublic, updateGame, createGame, 13 15 findGameRecordByParent, findGameRecordByParentPublic, ··· 34 36 let jsConnections: JetstreamConnection[] = []; 35 37 let connected = $state(false); 36 38 let lastPersistedPgn = ''; 39 + let gameLastActivity: string | undefined = $state(undefined); 40 + let rematchCreating = $state(false); 41 + let analyzingOnLichess = $state(false); 37 42 38 43 /** 39 44 * Verify an opponent's claimed game result. Returns a trusted ··· 156 161 const opponentColor = myColor === 'white' ? 'black' : 'white'; 157 162 await reconcileCompletion(opponentResult.record, opponentColor); 158 163 } 164 + if (game.status === 'active') { 165 + if (record.drawOffered) game.offerDraw(); 166 + if (opponentResult?.record.drawOffered) game.receiveDrawOffer(); 167 + } 168 + const dates = [record.lastMoveAt, opponentResult?.record.lastMoveAt, record.createdAt].filter(Boolean) as string[]; 169 + gameLastActivity = dates.sort().pop(); 170 + } else { 171 + gameLastActivity = record.lastMoveAt || record.createdAt; 159 172 } 160 173 } else if (isParticipant) { 161 174 const myResult = await findGameRecordByParent(auth.agent!, auth.did!, parentUri); ··· 169 182 const opponentColor = myColor === 'white' ? 'black' : 'white'; 170 183 await reconcileCompletion(record, opponentColor); 171 184 } 185 + if (game.status === 'active') { 186 + if (myResult.record.drawOffered) game.offerDraw(); 187 + if (record.drawOffered) game.receiveDrawOffer(); 188 + } 189 + const dates = [record.lastMoveAt, myResult.record.lastMoveAt, record.createdAt].filter(Boolean) as string[]; 190 + gameLastActivity = dates.sort().pop(); 172 191 } else { 173 192 await joinGame(record, myColor); 174 193 } ··· 250 269 251 270 function waitForOpponent() { 252 271 destroyConnections(); 272 + const expectedParentUri = `at://${ownerDid}/blue.checkmate.game/${rkey}`; 253 273 const js = new JetstreamConnection({ 254 274 opponentDid: '', 255 275 agent: auth.agent ?? undefined, 256 276 initialCursor: (Date.now() - 60_000) * 1000, 257 277 onGameUpdate: async (record) => { 278 + // Only react to opponent's record (has parentGameUri pointing to this game) 279 + if (record.parentGameUri !== expectedParentUri) return; 258 280 const white = record.white as string; 259 281 const black = record.black as string; 260 282 const weAreWhite = white === auth.did && black && black !== auth.did; ··· 296 318 agent: auth.agent ?? undefined, 297 319 onGameUpdate: async (record) => { 298 320 const pgn = record.pgn as string; 299 - if (pgn) { 300 - game.applyOpponentMove(pgn); 321 + if (pgn && game.applyOpponentMove(pgn)) { 322 + sound.play('notify'); 301 323 } 302 324 303 325 // Detect draw offer from opponent ··· 309 331 310 332 const status = record.status as string; 311 333 if (status === 'completed') { 334 + sound.play('gameEnd'); 312 335 const result = record.result as string; 313 336 const reason = record.resultReason as string; 314 337 if (result === '1/2-1/2' && reason === 'agreement' && game.drawOfferedByMe) { ··· 409 432 game.setHandles(white?.handle, black?.handle); 410 433 } 411 434 435 + function playMoveSound() { 436 + const history = game.chess.history({ verbose: true }); 437 + const last = history[history.length - 1]; 438 + sound.play(last?.captured ? 'capture' : 'move'); 439 + } 440 + 412 441 async function handleMove(orig: string, dest: string) { 413 442 const moved = game.tryMove(orig, dest); 414 443 if (moved) { 444 + playMoveSound(); 415 445 await writeMove(); 416 446 } 417 447 } ··· 419 449 async function handlePromotion(piece: PieceSymbol) { 420 450 const moved = game.completePromotion(piece); 421 451 if (moved) { 452 + playMoveSound(); 422 453 await writeMove(); 423 454 } 424 455 } ··· 434 465 result: game.result?.result, 435 466 resultReason: game.result?.reason as any, 436 467 drawOffered: false, 468 + lastMoveAt: new Date().toISOString(), 437 469 }); 438 470 game.clearDrawOffers(); 439 471 lastPersistedPgn = pgn; 440 472 moveError = ''; 441 473 if (game.result) { 442 474 game.setStatus('completed'); 475 + sound.play('gameEnd'); 443 476 } 444 477 } catch (e) { 445 478 console.error('[writeMove] failed, rolling back:', e); ··· 495 528 await updateGame(auth.agent, myRkey, { drawOffered: false }); 496 529 } 497 530 531 + async function handleRematch() { 532 + if (!auth.agent || !auth.did) return; 533 + rematchCreating = true; 534 + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; 535 + const result = await createGame(auth.agent, { 536 + white: opponentDid, 537 + black: auth.did, 538 + status: 'waiting', 539 + }); 540 + goto(`/game/${auth.did}/${result.rkey}`); 541 + } 542 + 543 + function downloadPgn() { 544 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 545 + const blob = new Blob([pgn], { type: 'application/x-chess-pgn' }); 546 + const url = URL.createObjectURL(blob); 547 + const a = document.createElement('a'); 548 + a.href = url; 549 + a.download = `checkmate-blue-${rkey}.pgn`; 550 + a.click(); 551 + URL.revokeObjectURL(url); 552 + } 553 + 554 + async function openLichessAnalysis() { 555 + analyzingOnLichess = true; 556 + try { 557 + const pgn = makePgn(game.chess, game.whiteDid, game.blackDid); 558 + const response = await fetch('https://lichess.org/api/import', { 559 + method: 'POST', 560 + headers: { 561 + 'Content-Type': 'application/x-www-form-urlencoded', 562 + 'Accept': 'application/json', 563 + }, 564 + body: `pgn=${encodeURIComponent(pgn)}`, 565 + }); 566 + const data = await response.json(); 567 + if (data.url) { 568 + window.open(data.url, '_blank'); 569 + } 570 + } catch (e) { 571 + // CORS fallback: open Lichess paste page with PGN in URL 572 + const pgn = game.chess.pgn(); 573 + window.open(`https://lichess.org/paste?pgn=${encodeURIComponent(pgn)}`, '_blank'); 574 + } finally { 575 + analyzingOnLichess = false; 576 + } 577 + } 578 + 579 + async function handleAbandon() { 580 + if (!auth.agent || !auth.did || !myRkey) return; 581 + const result = game.myColor === 'white' ? '1-0' : '0-1'; 582 + game.setStatus('completed'); 583 + game.setResult(result as '1-0' | '0-1', 'abandonment'); 584 + sound.play('gameEnd'); 585 + await updateGame(auth.agent, myRkey, { 586 + status: 'completed', 587 + result, 588 + resultReason: 'abandonment', 589 + }); 590 + } 591 + 498 592 function flipBoard() { 499 593 spectatorOrientation = spectatorOrientation === 'white' ? 'black' : 'white'; 500 594 } ··· 553 647 554 648 const isWaiting = $derived(game.status === 'waiting'); 555 649 const boardOrientation = $derived(isSpectator ? spectatorOrientation : game.myColor); 650 + 651 + const ABANDON_DAYS = 7; 652 + const canAbandon = $derived.by(() => { 653 + if (isSpectator) return false; 654 + if (game.status === 'waiting') return true; 655 + if (game.status !== 'active' || game.isMyTurn) return false; 656 + if (!gameLastActivity) return false; 657 + const daysSince = (Date.now() - new Date(gameLastActivity).getTime()) / 86_400_000; 658 + return daysSince >= ABANDON_DAYS; 659 + }); 660 + const daysSinceActivity = $derived( 661 + gameLastActivity ? Math.floor((Date.now() - new Date(gameLastActivity).getTime()) / 86_400_000) : 0 662 + ); 556 663 557 664 const opponentHandle = $derived( 558 665 game.myColor === 'white' ? game.blackHandle : game.whiteHandle ··· 683 790 insufficient: 'Insufficient material', 684 791 repetition: 'Threefold repetition', 685 792 fifty_moves: 'Fifty-move rule', 793 + agreement: 'Draw by agreement', 794 + abandonment: 'Opponent inactive', 686 795 }} 687 796 <div class="rounded-lg p-4 text-center {iWin ? 'bg-success/10 border border-success' : iLose ? 'bg-danger/10 border border-danger' : 'bg-bg-secondary border border-border'}"> 688 797 <p class="text-lg font-bold"> ··· 703 812 {/if} 704 813 </p> 705 814 <p class="text-sm text-text-secondary">{reasonLabels[game.result.reason] ?? game.result.reason}</p> 815 + {#if !isSpectator} 816 + <div class="mt-3 flex flex-wrap justify-center gap-2"> 817 + <button 818 + onclick={handleRematch} 819 + disabled={rematchCreating} 820 + class="rounded-lg bg-accent-blue px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-accent-blue-hover disabled:opacity-50" 821 + > 822 + {rematchCreating ? 'Creating...' : 'Rematch'} 823 + </button> 824 + <button 825 + onclick={downloadPgn} 826 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary" 827 + > 828 + Download PGN 829 + </button> 830 + <button 831 + onclick={openLichessAnalysis} 832 + disabled={analyzingOnLichess} 833 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" 834 + > 835 + {analyzingOnLichess ? 'Opening...' : 'Analyze on Lichess'} 836 + </button> 837 + </div> 838 + {:else} 839 + <div class="mt-3 flex flex-wrap justify-center gap-2"> 840 + <button 841 + onclick={downloadPgn} 842 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary" 843 + > 844 + Download PGN 845 + </button> 846 + <button 847 + onclick={openLichessAnalysis} 848 + disabled={analyzingOnLichess} 849 + class="rounded-lg border border-border px-4 py-1.5 text-sm text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" 850 + > 851 + {analyzingOnLichess ? 'Opening...' : 'Analyze on Lichess'} 852 + </button> 853 + </div> 854 + {/if} 706 855 </div> 707 856 {:else if game.isInCheck && game.status === 'active'} 708 857 <div class="rounded-lg border border-warning bg-warning/10 px-4 py-2 text-center text-sm font-semibold text-warning"> ··· 727 876 ondrawaccept={handleDrawAccept} 728 877 ondrawdecline={handleDrawDecline} 729 878 /> 879 + {#if canAbandon} 880 + <button 881 + onclick={handleAbandon} 882 + class="text-sm text-text-secondary transition-colors hover:text-danger" 883 + > 884 + {game.status === 'waiting' ? 'Cancel game' : `Claim win (opponent inactive ${daysSinceActivity}d)`} 885 + </button> 886 + {/if} 730 887 {/if} 731 888 732 889 <MoveList chess={game.chess} /> 733 890 734 - {#if game.status !== 'completed'} 735 - <div class="flex items-center gap-2 text-xs text-text-secondary"> 736 - <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 737 - {connected ? 'Live' : 'Reconnecting...'} 738 - </div> 739 - {/if} 891 + <div class="flex items-center gap-3 text-xs text-text-secondary"> 892 + {#if game.status !== 'completed'} 893 + <div class="flex items-center gap-2"> 894 + <span class="inline-block h-2 w-2 rounded-full" class:bg-success={connected} class:bg-danger={!connected}></span> 895 + {connected ? 'Live' : 'Reconnecting...'} 896 + </div> 897 + {/if} 898 + <button 899 + onclick={() => sound.toggle()} 900 + class="transition-colors hover:text-text-primary" 901 + title={sound.muted ? 'Unmute' : 'Mute'} 902 + > 903 + {sound.muted ? 'Sound off' : 'Sound on'} 904 + </button> 905 + </div> 740 906 </div> 741 907 742 908 {#if game.pendingPromotion && !isSpectator}
+15
static/manifest.json
··· 1 + { 2 + "name": "checkmate.blue", 3 + "short_name": "checkmate", 4 + "start_url": "/", 5 + "display": "standalone", 6 + "background_color": "#0f1419", 7 + "theme_color": "#1d9bf0", 8 + "icons": [ 9 + { 10 + "src": "/favicon.svg", 11 + "sizes": "any", 12 + "type": "image/svg+xml" 13 + } 14 + ] 15 + }
static/sounds/capture.mp3

This is a binary file and will not be displayed.

static/sounds/move.mp3

This is a binary file and will not be displayed.

static/sounds/notify.mp3

This is a binary file and will not be displayed.