diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..3011702 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,234 @@ +# checkmate.blue Roadmap + +## Phase 1: Challenge & Invite Flow -- DONE + +Implemented on branch `phase-1-challenge-invite-flow`. + +- **1a. Post-Based Challenges** -- "Post to Bluesky" button on the game waiting screen with @mention facets and game link. +- **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). +- **1c. Color Selection** -- White/Black/Random picker on `/play`. Game page logic fully decoupled from record ownership. +- **Check-before-join guard** -- re-fetches game record before joining to reduce race conditions on shared links. + +See `plans/phase-1-challenge-invite-flow.md` for full details. + +--- + +## Phase 3: Spectator Mode -- DONE + +Pulled forward and implemented alongside Phase 1. + +- **Read-only game view** for non-participants with flip-board button. +- **Unauthenticated viewing** -- no login required to watch a game. +- **Dual Jetstream connections** -- spectators receive live updates from both players. +- **Game-full fallback** -- extra visitors become spectators, not errors. + +See `plans/phase-3-spectator-mode.md` for full details. + +--- + +## Phase 2: Shareability & Branding -- NEXT + +Priority: **High**. Links shared on social platforms currently show bare URLs. A recognizable brand and rich link previews drive organic growth. + +### 2a. Logo + +FontAwesome chess knight icon in AT Protocol blue (`#0085FF`). + +**Usage:** +- Favicon (SVG) +- Navbar icon (32x32) +- OG image fallback (centered on dark background) +- PWA icon (192x192, 512x512) + +**Implementation:** +- Create an SVG from the FA `chess-knight` glyph, colored `#0085FF`. +- Generate PNG variants at required sizes for PWA manifest and Apple touch icon. +- Replace the current placeholder favicon. + +### 2b. Open Graph Meta Tags + +Rich link previews when sharing game/challenge URLs on Bluesky, Twitter, Discord, etc. + +**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. + +**Options (in order of preference):** + +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. + +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. + +3. **Static fallback only** -- use the same generic card (logo + "checkmate.blue - Chess on the Atmosphere") for all links. Easiest but least compelling. + +**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. + +**Meta tags for different routes:** + +| Route | og:title | og:description | +|-------|----------|---------------| +| `/` | checkmate.blue | Chess on the Atmosphere | +| `/game/{did}/{rkey}` | Chess Game - {white} vs {black} | {status} - {move count} moves | +| `/challenge/{did}/{rkey}` | Chess Challenge from {handle} | {handle} wants to play chess | +| `/profile/{handle}` | {handle} on checkmate.blue | Chess profile | + +--- + +## Phase 4: Homepage & Game Discovery + +Priority: **Medium**. Makes the platform feel alive and gives new visitors something to see. + +### 4a. Active Games List + +Show ongoing games on the homepage, sorted by most recent activity. + +**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. + +**Implementation:** + +- Query approach TBD -- depends on what Constellation supports for global queries. Options: + - Query Constellation for all `blue.checkmate.game` records globally (if supported). + - Maintain a known-players list and query each player's games (doesn't scale). + - Use the Jetstream firehose to build a client-side index of recent games (complex, ephemeral). + - Add a lightweight indexing endpoint (breaks the "no server" constraint). +- Filter to `status: active` and `parentGameUri` absent (White's record only). +- Sort by last activity. **Requires a `lastMoveAt` field on the lexicon** -- PGN parsing for sort order is too expensive for a list view. + +**Lexicon change:** +``` +"lastMoveAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the most recent move" +} +``` + +This field gets updated on every `putRecord` call when a move is made. + +- Display each game as a card: White handle vs Black handle, move count, last activity time, link to spectate. + +### 4b. Completed Games Feed + +Below active games, show recently completed games with results. + +- Same query approach as active games, filtered to `status: completed`. +- Show result (1-0, 0-1, draw), result reason, player handles. +- Links to view the final position. + +--- + +## Phase 5: Game Result Sharing + +Priority: **Medium**. The viral loop. Players share their wins (and losses) to Bluesky. + +**Implementation:** + +- After a game ends (checkmate, resignation, draw), show a "Share to Bluesky" button on the game page. +- Create a `app.bsky.feed.post` record with: + - Text describing the result: "Checkmate! I beat @{opponent} on checkmate.blue" / "Good game -- @{opponent} got me this time" / "Draw with @{opponent} on checkmate.blue" + - Mention facet for the opponent + - Link facet to the game URL + - Embed with game link card (benefits from Phase 2 OG tags) +- Include move count and result reason in the post text for flavor. +- This is always opt-in. Show a pre-filled post that the player can edit before posting. + +**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. + +--- + +## Phase 6: Polish + +Priority: **Lower**. Quality-of-life improvements that make gameplay feel complete. + +### 6a. Sound Effects + +- Move sound (piece placement) +- Capture sound +- Check sound +- Game over sound (checkmate, draw) +- Opponent move notification sound + +Use small audio files (MP3/OGG). Lichess sounds are BSD-licensed and could be used directly. + +### 6b. Rematch Button + +After a game ends, show a "Rematch" button that creates a new game with the same opponent, colors swapped. + +- Creates a new `blue.checkmate.game` record with White/Black reversed. +- The rematch is essentially a new challenge to the same opponent, auto-linked. +- Could use the challenge record to track the rematch offer, or just create the game directly and show the link. + +### 6c. PGN Export & Analysis + +- "Download PGN" button on completed (or in-progress) games. +- "Analyze on Lichess" button that opens the Lichess analysis board with the game's PGN. +- Lichess analysis URL: `https://lichess.org/analysis/pgn/{url-encoded-pgn}` + +### 6d. Abandoned Game Detection + +- If a game has been inactive for a configurable period (e.g., 7 days), show an "Abandon" option to the waiting player. +- The abandoning player writes `status: abandoned` to their record. +- Could also auto-mark games as abandoned based on `lastMoveAt` (requires the lexicon change from Phase 4). +- No server-side enforcement -- this is a convention. A bot (separate project) could handle automated cleanup. + +### 6e. Mobile PWA + +- Add a `manifest.json` with app name, icons (from Phase 2a logo), theme color. +- Add a service worker for offline shell caching. +- The static SvelteKit build is already most of the way there. + +--- + +## Phase 7: Open Challenge Board (Deferred) + +Priority: **Lower**. Allow players to browse and accept open challenges from anyone, not just targeted invites. + +- Add a public list of open challenges to the homepage via Constellation queries. +- Requires investigation into whether Constellation supports global collection queries (not just DID-targeted). +- Display open challenges with challenger handle, creation time. +- Filter out expired challenges client-side (e.g., older than 24h). +- Auto-expiry is a convention (UI hides old challenges), not enforced server-side. + +--- + +## Separate Project: checkmate.blue Bot + +**Not part of this project.** Tracked here for reference. + +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). + +Responsibilities: +- Watch for `status: completed` game records on Jetstream. +- Post game results to the bot's Bluesky feed. +- Potentially: abandoned game detection, global game indexing, rating calculation. + +--- + +## Lexicon Changes Summary + +Changes needed across the remaining roadmap: + +| Field | Lexicon | Phase | Status | +|-------|---------|-------|--------| +| `challengerColor` | `blue.checkmate.challenge` | 1c | DONE | +| `lastMoveAt` | `blue.checkmate.game` | 4a | Pending | + +--- + +## Dependency Graph + +``` +Phase 1 (Challenge Invite Flow) ── DONE +Phase 3 (Spectator Mode) ── DONE + +Phase 2a (Logo) ─────────────────── prerequisite for 2b +Phase 2b (OG Tags) ─────────────── benefits 5 + +Phase 4a (Active Games List) ────── requires lexicon change (lastMoveAt) +Phase 4b (Completed Games Feed) ── same query infrastructure as 4a + +Phase 5 (Game Result Sharing) ──── benefits from 2b (OG tags make shared links look good) + reuses bluesky.ts from Phase 1 + +Phase 6 (Polish) ───────────────── independent, can be interleaved anywhere + +Phase 7 (Open Challenge Board) ─── deferred, depends on Constellation global query support +``` diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..f017f10 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,750 @@ +# checkmate.blue — Technical Specification v2 + +## Overview + +**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. + +**Target:** Working PoC in 2 days. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ Browser │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ SvelteKit │ │ @atproto/oauth-client │ │ +│ │ UI + Pages │ │ -browser │ │ +│ │ │ │ (IndexedDB sessions) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +│ │ │ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ chessground │ │ chess.js │ │ +│ │ (board UI) │ │ (validation + PGN) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌───────────────────────────────────────────┐ │ +│ │ atproto Agent (authenticated) │ │ +│ │ • putRecord → player's PDS (write moves) │ │ +│ │ • getRecord → opponent's PDS (read game) │ │ +│ └───────────────────────────────────────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ Jetstream │ │ Constellation / │ │ +│ │ (WebSocket) │ │ Slingshot │ │ +│ │ opponent │ │ (game discovery, │ │ +│ │ move events │ │ handle resolution) │ │ +│ └──────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────┘ + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────────┐ + │ Jetstream │ │ Player's PDS │ + │ (Bluesky) │ │ (e.g. bsky.social) │ + └───────────┘ └─────────────────────┘ +``` + +**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. + +--- + +## Stack + +| Layer | Technology | +|-------|-----------| +| Framework | SvelteKit (static adapter or minimal node adapter) | +| Chess logic | `chess.js` (client-side validation, PGN generation) | +| Board UI | `chessground` (Lichess BSD-licensed board) | +| Auth | `@atproto/oauth-client-browser` (sessions in IndexedDB) | +| AT Protocol | `@atproto/api` (read/write records to PDS) | +| Real-time | Jetstream WebSocket (listen for opponent's moves) | +| Queries | Constellation (game discovery, backlinks) | +| Identity | Slingshot (handle ↔ DID resolution, record cache) | +| Styling | Tailwind CSS | +| Domain | `checkmate.blue` | + +### Key dependencies + +```json +{ + "dependencies": { + "@atproto/api": "latest", + "@atproto/oauth-client-browser": "latest", + "chess.js": "latest", + "chessground": "latest", + "svelte": "latest", + "@sveltejs/kit": "latest" + }, + "devDependencies": { + "@sveltejs/adapter-static": "latest", + "autoprefixer": "latest", + "postcss": "latest", + "tailwindcss": "latest", + "typescript": "latest", + "vite": "latest" + } +} +``` + +--- + +## AT Protocol Lexicons + +Namespace: `blue.checkmate.*` + +### Game Record — `blue.checkmate.game` + +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`. + +```json +{ + "lexicon": 1, + "id": "blue.checkmate.game", + "defs": { + "timeControl": { + "type": "object", + "description": "Time control settings for the game (not yet implemented)", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "knownValues": ["untimed", "correspondence", "clock"] + }, + "initialSeconds": { + "type": "integer", + "description": "Starting time per player in seconds" + }, + "incrementSeconds": { + "type": "integer", + "description": "Seconds added after each move (Fischer increment)" + }, + "moveTimeLimitSeconds": { + "type": "integer", + "description": "Max seconds per move for correspondence games" + } + } + }, + "main": { + "type": "record", + "key": "tid", + "description": "A chess game on checkmate.blue", + "record": { + "type": "object", + "required": ["pgn", "createdAt", "status"], + "properties": { + "pgn": { + "type": "string", + "maxLength": 100000, + "description": "PGN of the game including headers and moves so far" + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "white": { + "type": "string", + "format": "did", + "description": "DID of the white player" + }, + "black": { + "type": "string", + "format": "did", + "description": "DID of the black player" + }, + "status": { + "type": "string", + "knownValues": ["waiting", "active", "completed", "abandoned"] + }, + "result": { + "type": "string", + "knownValues": ["1-0", "0-1", "1/2-1/2"] + }, + "resultReason": { + "type": "string", + "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"] + }, + "parentGameUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of White's game record, set on Black's copy" + }, + "drawOffered": { + "type": "boolean", + "description": "Whether a draw has been offered by the last moving player" + }, + "timeControl": { + "type": "ref", + "ref": "#timeControl" + }, + "moveTimes": { + "type": "array", + "items": { "type": "integer" }, + "description": "Seconds elapsed for each half-move, in ply order" + } + } + } + } + } +} +``` + +> **Note:** `timeControl` and `moveTimes` are defined in the lexicon but not yet implemented. All games are currently untimed. + +### Challenge Record — `blue.checkmate.challenge` + +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. + +```json +{ + "lexicon": 1, + "id": "blue.checkmate.challenge", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "A challenge to play chess on checkmate.blue", + "record": { + "type": "object", + "required": ["createdAt", "status"], + "properties": { + "createdAt": { + "type": "string", + "format": "datetime" + }, + "opponent": { + "type": "string", + "format": "did", + "description": "DID of the specific opponent, or omit for open challenge" + }, + "gameUri": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the game record once accepted" + }, + "status": { + "type": "string", + "knownValues": ["open", "accepted", "expired", "cancelled"] + } + } + } + } + } +} +``` + +--- + +## Authentication + +Use `@atproto/oauth-client-browser` for fully client-side OAuth. Sessions are stored in the browser's IndexedDB — no server-side session management needed. + +### Setup + +```typescript +// src/lib/oauth.ts +import { BrowserOAuthClient } from '@atproto/oauth-client-browser'; + +export const oauthClient = new BrowserOAuthClient({ + clientMetadata: { + client_id: 'https://checkmate.blue/oauth/client-metadata.json', + client_name: 'checkmate.blue', + client_uri: 'https://checkmate.blue', + redirect_uris: ['https://checkmate.blue/oauth/callback'], + scope: 'atproto transition:generic', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + application_type: 'web', + dpop_bound_access_tokens: true, + }, + handleResolver: 'https://bsky.social', +}); +``` + +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. + +### Flow + +1. User clicks "Sign in with Bluesky" and enters their handle +2. `oauthClient.signIn(handle)` redirects to their PDS +3. User authorizes, PDS redirects back to `/oauth/callback` +4. `oauthClient.init()` on page load restores sessions from IndexedDB +5. Create an `Agent` from the session for all atproto operations + +```typescript +import { Agent } from '@atproto/api'; + +const session = await oauthClient.restore(did); +const agent = new Agent(session); +// agent is now authenticated — can read/write to user's PDS +``` + +### Client Metadata Endpoint + +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. + +--- + +## Game Flow + +### 1. Create Challenge + +Player A creates a challenge record in their own repo: + +```typescript +const challenge = await agent.com.atproto.repo.createRecord({ + repo: agent.session.did, + collection: 'blue.checkmate.challenge', + record: { + $type: 'blue.checkmate.challenge', + createdAt: new Date().toISOString(), + status: 'open', + // opponent: 'did:plc:bob' — optional, omit for open challenge + }, +}); +// Share the AT URI or derive a challenge URL from it +``` + +The challenge URL is constructed from the AT URI: `https://checkmate.blue/challenge/{did}/{rkey}` + +### 2. Accept Challenge + +Player B views the challenge, clicks accept. Player A's client creates the game record and updates the challenge: + +**Who creates the game record?** The challenger (Player A). Their client watches for challenge acceptance (via Jetstream or polling) and then: + +1. Creates a `blue.checkmate.game` record in their repo with both players assigned +2. Updates the challenge record with the game URI and status `accepted` + +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. + +```typescript +// Player A creates game + challenge together +const game = await agent.com.atproto.repo.createRecord({ + repo: agent.session.did, + collection: 'blue.checkmate.game', + record: { + $type: 'blue.checkmate.game', + pgn: '[Event "checkmate.blue"]\n[White ""]\n[Black ""]\n[Result "*"]\n\n*', + createdAt: new Date().toISOString(), + white: agent.session.did, // challenger plays white + status: 'waiting', + moveCount: 0, + }, +}); +// Share game URL: https://checkmate.blue/game/{did}/{rkey} +``` + +When Player B opens the URL, the game record is updated with their DID as black and status becomes `active`. + +### 3. Making Moves + +The current player validates the move client-side with chess.js, then writes the updated game state to the **game owner's** repo. + +**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. + +**Solution: Each player writes their moves to their OWN repo as a move record, and the opponent's client reads it.** + +Revised approach — **two records, one per player**: + +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. + +``` +Player A's repo: + blue.checkmate.game/{rkey-A} + { white: A, black: B, pgn: "1. e4 e5 2. Nf3", lastMoveBy: A, ... } + +Player B's repo: + blue.checkmate.game/{rkey-B} + { white: A, black: B, pgn: "1. e4 e5 2. Nf3 Nc6", lastMoveBy: B, ... } +``` + +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. + +**Move validation flow:** + +1. Player A makes a move in the UI +2. Client validates with chess.js — is it legal? Is it A's turn? +3. Client updates Player A's game record with the new PGN via `putRecord` +4. Player B's client receives the update via Jetstream +5. Player B's client reads the updated PGN, validates the new move with chess.js +6. If valid, updates the local board state +7. Player B makes their move, writes to their own record +8. Player A receives via Jetstream, validates, updates board + +**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. + +### 4. Game Over + +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. + +### 5. Resignation / Draw + +Resign: player writes `status: completed`, `result` favoring opponent, `resultReason: resignation` to their own record. + +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`. + +--- + +## Jetstream Integration + +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. + +### Connection + +```typescript +// Connect to Jetstream, filtered by opponent's DID and our game collection +const jetstreamUrl = new URL('wss://jetstream2.us-east.bsky.network/subscribe'); +jetstreamUrl.searchParams.set('wantedCollections', 'blue.checkmate.game'); +jetstreamUrl.searchParams.set('wantedDids', opponentDid); + +const ws = new WebSocket(jetstreamUrl.toString()); + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.kind === 'commit' && data.commit.operation === 'update') { + // Opponent updated their game record — they made a move + const record = data.commit.record; + if (record.pgn) { + // Validate the new move with chess.js + // Update the local board + } + } +}; +``` + +### Jetstream Event Structure + +Events arrive as JSON with a `kind` field. For record updates: + +```typescript +{ + kind: 'commit', + did: 'did:plc:opponent', + commit: { + operation: 'update', // or 'create', 'delete' + collection: 'blue.checkmate.game', + rkey: 'abc123', + record: { /* the full updated record */ }, + cid: 'bafyrei...' + }, + time_us: 1234567890 +} +``` + +### Fallback Polling + +If Jetstream connection drops, fall back to polling `getRecord` every 3 seconds until WebSocket reconnects: + +```typescript +const response = await agent.com.atproto.repo.getRecord({ + repo: opponentDid, + collection: 'blue.checkmate.game', + rkey: opponentRkey, +}); +``` + +--- + +## Microcosm Integration + +### Constellation — Game Discovery + +Query Constellation to find all games a player is involved in, without maintaining a local index. + +```typescript +// Find all game records that link to a player's DID +const response = await fetch( + `https://constellation.microcosm.blue/links/all?` + + `target=${encodeURIComponent(playerDid)}` + + `&collection=blue.checkmate.game` +); +``` + +This returns all records across the network that reference the player's DID — i.e., games where they appear as `white` or `black`. + +### Slingshot — Handle Resolution & Record Cache + +Resolve DIDs to handles (and vice versa) and get fast cached access to records: + +```typescript +// Resolve a DID to handle/profile info +const response = await fetch( + `https://slingshot.microcosm.blue/xrpc/com.bad-example.identity.resolveMiniDoc?` + + `identifier=${encodeURIComponent(did)}` +); +``` + +Useful for displaying opponent handles/avatars without querying the PDS directly. + +--- + +## Pages & Routes + +### `/` — Landing Page + +- Logo/mascot placeholder + tagline +- "Sign in with Bluesky" button +- If authenticated: player handle, avatar, quick actions +- Active games list (fetched from Constellation or direct PDS query) + +### `/play` — Create or Browse Challenges + +- Create a new challenge (open or directed at a specific handle) +- Browse open challenges from other players (via Constellation backlinks on the player declaration records) + +### `/challenge/{did}/{rkey}` — View / Accept Challenge + +- Shows who created the challenge +- "Accept & Play" button +- On accept: creates game records, redirects to game page + +### `/game/{did}/{rkey}` — Active Game + +The main gameplay page. The URL contains the game owner's DID and record key. + +**Layout (mobile-first):** + +``` +┌──────────────────────────┐ +│ Opponent handle + avatar │ +├──────────────────────────┤ +│ │ +│ Chessground Board │ +│ (responsive, square) │ +│ │ +├──────────────────────────┤ +│ Your handle + avatar │ +├──────────────────────────┤ +│ [Resign] [Offer Draw] │ +└──────────────────────────┘ +``` + +Desktop: board centered, player info and move list in a sidebar. + +**Chessground integration:** + +```typescript +import { Chessground } from 'chessground'; +import 'chessground/assets/chessground.base.css'; +import 'chessground/assets/chessground.brown.css'; +import 'chessground/assets/chessground.cburnett.css'; + +const ground = Chessground(boardElement, { + fen: currentFen, + orientation: playerColor, + turnColor: turnColor, + movable: { + free: false, + color: playerColor, + dests: legalMoves, // computed from chess.js + }, + events: { + move: (orig, dest) => handleMove(orig, dest), + }, +}); +``` + +**Promotion handling:** When a pawn reaches the 8th rank, show a modal with piece choices (Q/R/B/N) before writing the move. + +### `/profile/{handle}` — Player Profile (stretch) + +- Display name, handle, avatar from atproto profile +- Game history via Constellation queries +- Win/loss/draw record + +### `/oauth/callback` — OAuth Redirect + +Handles the OAuth callback. The `BrowserOAuthClient` processes the redirect params and stores the session in IndexedDB. + +### `/oauth/client-metadata.json` — OAuth Client Metadata + +Static JSON endpoint required by the atproto OAuth spec. Must be publicly accessible. + +--- + +## File Structure + +``` +checkmate-blue/ +├── src/ +│ ├── lib/ +│ │ ├── oauth.ts # BrowserOAuthClient setup +│ │ ├── atproto.ts # Agent creation, record read/write helpers +│ │ ├── game-logic.ts # chess.js wrapper, move validation, PGN ops +│ │ ├── jetstream.ts # Jetstream WebSocket connection + filtering +│ │ ├── microcosm.ts # Constellation + Slingshot query helpers +│ │ ├── stores/ +│ │ │ ├── auth.ts # Current user / agent store +│ │ │ ├── game.ts # Active game state store +│ │ │ └── jetstream.ts # Jetstream connection state +│ │ ├── components/ +│ │ │ ├── Board.svelte # Chessground wrapper +│ │ │ ├── PlayerBar.svelte # Avatar + handle display +│ │ │ ├── GameControls.svelte # Resign, draw offer buttons +│ │ │ ├── PromotionModal.svelte +│ │ │ ├── MoveList.svelte # PGN move list display +│ │ │ └── LoginButton.svelte # "Sign in with Bluesky" +│ │ └── types.ts # Shared TypeScript types +│ ├── routes/ +│ │ ├── +layout.svelte # Global layout, auth init +│ │ ├── +page.svelte # Landing page +│ │ ├── play/ +│ │ │ └── +page.svelte # Create / browse challenges +│ │ ├── challenge/ +│ │ │ └── [did]/ +│ │ │ └── [rkey]/ +│ │ │ └── +page.svelte # View / accept challenge +│ │ ├── game/ +│ │ │ └── [did]/ +│ │ │ └── [rkey]/ +│ │ │ └── +page.svelte # Main game board +│ │ ├── profile/ +│ │ │ └── [handle]/ +│ │ │ └── +page.svelte # Player profile +│ │ └── oauth/ +│ │ ├── callback/ +│ │ │ └── +page.svelte # OAuth callback handler +│ │ └── client-metadata.json/ +│ │ └── +server.ts # Serve OAuth client metadata +│ └── app.css # Tailwind + chessground overrides +├── lexicons/ +│ ├── blue.checkmate.game.json +│ └── blue.checkmate.challenge.json +├── static/ +│ ├── favicon.svg +│ └── mascot-placeholder.svg +├── svelte.config.js +├── tailwind.config.ts +├── tsconfig.json +└── package.json +``` + +--- + +## Design System + +### Visual Direction + +Dark theme, blue accents. Minimal, clean, mobile-first. + +### Mascot / Logo + +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). + +### Color Palette + +```css +:root { + --bg-primary: #0f1419; + --bg-secondary: #1a2332; + --bg-board: #2a3a4a; + --accent-blue: #1d9bf0; + --accent-blue-hover: #1a8cd8; + --text-primary: #e7e9ea; + --text-secondary: #71767b; + --success: #00ba7c; + --danger: #f4212e; + --warning: #ffd400; + --border: #2f3336; +} +``` + +### Typography + +System sans-serif stack for body and headings. Monospace (`JetBrains Mono` or system monospace) for move notation. + +--- + +## Implementation Order (2-Day Plan) + +### Day 1: Auth + Board + Protocol Writes + +**Morning:** +1. Scaffold SvelteKit project from Bailey's atproto template or flo-bit's client-side OAuth scaffold +2. Adapt OAuth for `@atproto/oauth-client-browser` +3. Serve `client-metadata.json` endpoint +4. Test: can log in with a Bluesky account, get an authenticated agent + +**Afternoon:** +5. Integrate chessground Board component with chess.js +6. Implement `createRecord` for game creation +7. Implement `putRecord` for move writes +8. Build game page that loads game state from PDS on mount +9. Test: can create a game, make moves, see them written to PDS + +### Day 2: Real-time + Polish + +**Morning:** +10. Connect to Jetstream, filter for opponent's DID + game collection +11. When Jetstream delivers an update, validate and apply the opponent's move +12. Add fallback polling for when Jetstream disconnects +13. Test: two browsers, two accounts, can play a full game in real-time + +**Afternoon:** +14. Challenge creation and acceptance flow +15. Resign / draw offer +16. Landing page with branding and auth +17. Responsive layout polish (mobile board sizing) +18. Deploy to Linode (or static host), configure domain +19. Swap in mascot art if available + +--- + +## Edge Cases & Considerations + +- **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. +- **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. +- **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. +- **Record conflicts.** Since it's turn-based and each player only writes to their own record, there are no concurrent write conflicts. +- **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. +- **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. +- **OAuth token expiry.** `@atproto/oauth-client-browser` handles token refresh automatically via IndexedDB. Shorter token lifetimes (public client) are fine for gameplay sessions. + +--- + +## Deployment + +### Static Hosting (simplest) + +Since there's no server-side logic beyond serving static files and the client-metadata endpoint, the app can be deployed to: +- **Vercel** — SvelteKit has a Vercel adapter +- **Cloudflare Pages** — near-zero latency +- **GitHub Pages** — free, simple +- **Linode** — if you want full control, serve via Caddy + +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. + +### Custom Domain + +Configure `checkmate.blue` DNS to point to the hosting provider. If using Caddy on Linode: + +```caddyfile +checkmate.blue { + root * /var/www/checkmate-blue + file_server + try_files {path} /index.html # SPA fallback +} +``` + +--- + +## What This Spec Does NOT Cover (v2+) + +- Server-side move validation +- ELO rating calculation +- Spectator mode +- Tournament system +- Full chess clocks / time controls +- Anti-cheat +- Custom PDS or relay +- Chat / messaging +- Sound effects +- Custom board themes / piece sets +- Move timer / timeout enforcement +- Game search / filtering beyond Constellation queries +- Bluesky post integration (sharing game results) diff --git a/lexicons/blue.checkmate.game.json b/lexicons/blue.checkmate.game.json index 4ddd6d5..677c36e 100644 --- a/lexicons/blue.checkmate.game.json +++ b/lexicons/blue.checkmate.game.json @@ -65,9 +65,14 @@ }, "resultReason": { "type": "string", - "knownValues": ["checkmate", "resignation", "draw_agreement", "stalemate", "insufficient", "repetition", "fifty_moves"], + "knownValues": ["checkmate", "resignation", "agreement", "stalemate", "insufficient", "repetition", "fifty_moves", "abandonment"], "description": "Reason for the game result" }, + "lastMoveAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of the most recent move" + }, "parentGameUri": { "type": "string", "format": "at-uri", diff --git a/plans/e2e-test-plan.md b/plans/e2e-test-plan.md new file mode 100644 index 0000000..25bfaf7 --- /dev/null +++ b/plans/e2e-test-plan.md @@ -0,0 +1,166 @@ +# E2E Test Plan -- Phase 1 + Spectator Mode + +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). + +**Accounts:** Call them Account A and Account B. Use separate browser profiles or windows so their sessions don't conflict. + +--- + +## Test 1: Targeted Challenge (White) + +**Tests:** Game creation with color selection, opponent resolution, challenge acceptance, basic gameplay. + +1. Sign in as Account A. +2. Go to `/play`. +3. Enter Account B's handle in the opponent field. +4. Leave color selection on "White" (default). +5. Click "Challenge Player". +6. Verify you land on `/game/{A-did}/{rkey}` with the "Waiting for opponent" panel. +7. Verify the link input shows the correct game URL. +8. Verify the "Post to Bluesky" and "Send via DM" buttons are visible. +9. In the second browser, sign in as Account B. +10. Navigate to the game URL from step 6. +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. +12. Make a move as Account A (White). Verify Account B's board updates within a few seconds. +13. Make a move as Account B (Black). Verify Account A's board updates. + +--- + +## Test 2: Targeted Challenge (Black) + +**Tests:** Challenger-as-black color assignment. + +1. Sign in as Account A. Go to `/play`. +2. Enter Account B's handle. +3. Select "Black" in the color selector. +4. Click "Challenge Player". +5. Verify you land on the game page. The board should show from Black's perspective (black pieces at bottom). +6. In the second browser, sign in as Account B and navigate to the game URL. +7. **Expected:** Account B joins as White. Their board shows from White's perspective. +8. Account B makes the first move (they're White). Verify Account A sees it. +9. Account A makes a move. Verify Account B sees it. + +--- + +## Test 3: Random Color + +**Tests:** Random color assignment. + +1. Sign in as Account A. Go to `/play`. +2. Enter Account B's handle. +3. Select "Random". +4. Click "Challenge Player". +5. Note which color Account A was assigned (check the board orientation). +6. Have Account B join via the game URL. +7. **Expected:** Account B gets the opposite color. Both boards orient correctly. + +--- + +## Test 4: Post Challenge to Bluesky + +**Tests:** Bluesky post creation with mention facet. + +1. Create a targeted challenge (any color) as Account A. +2. On the waiting screen, click "Post to Bluesky". +3. **Expected:** Button changes to "Posted!". +4. Open Account A's Bluesky profile (bsky.app). Verify a post exists mentioning Account B with the game link. +5. On Account B's Bluesky, verify they received a notification (mention). +6. Click the game link in the post. **Expected:** It navigates to the game page. + +--- + +## Test 5: Send via DM + +**Tests:** DM workflow (clipboard + messages page). + +1. Create a targeted challenge as Account A. +2. On the waiting screen, click "Send via DM". +3. **Expected:** Button text changes to "Link copied! Paste in DM". A new tab opens to `bsky.app/messages`. +4. Verify the game URL is in your clipboard (paste it somewhere to check). +5. In the Bluesky messages tab, find or start a conversation with Account B and paste the link. + +--- + +## Test 6: Spectator Mode (Logged In) + +**Tests:** Third-party viewing of an active game. + +1. Start a game between Account A and Account B (use a targeted challenge, have both join). +2. Make a few moves so the game is active. +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). +4. Navigate to the game URL. +5. **Expected:** + - Board loads showing the current position from White's perspective. + - "Spectating" label is visible. + - "Flip board" button is visible. + - No resign/draw buttons. + - No legal move indicators when hovering pieces. + - The board is not interactive (can't drag pieces). +6. Click "Flip board". **Expected:** Board orientation toggles to Black's perspective. +7. Have one of the players make a move. **Expected:** The spectator's board updates in real-time. + +--- + +## Test 7: Spectator Mode (Not Logged In) + +**Tests:** Unauthenticated game viewing. + +1. Start an active game between Account A and Account B. +2. Open a fresh incognito window (not signed in). +3. Navigate to the game URL. +4. **Expected:** + - Board loads with the current position. No sign-in prompt blocking the view. + - "Spectating" label and "Flip board" button visible. + - "Live" connection indicator visible (Jetstream connects for spectators). + - A small "Sign in to play" section appears below the board (not blocking the view). +5. Have a player make a move. **Expected:** Spectator board updates live. + +--- + +## Test 8: Game Full -- Graceful Fallback + +**Tests:** Multiple people clicking the same game link. + +1. Account A creates an open game (no opponent handle, just click "Create Open Game"). +2. Share the game URL with Account B. +3. Account B navigates to the URL and joins (fills the empty slot). +4. Now open the same URL in a third browser/profile, signed in as a different account. +5. **Expected:** The third person sees the game in spectator mode, not an error. Both player slots are already filled. + +--- + +## Test 9: Check-Before-Join Guard + +**Tests:** Race condition mitigation when two people try to join simultaneously. + +This is hard to trigger manually but you can approximate it: + +1. Account A creates an open game. +2. Account B opens the game URL but does NOT let it finish loading (throttle network in dev tools, or just be ready). +3. Meanwhile, have Account C open the same URL and let it load fully -- Account C joins. +4. Now let Account B's page finish loading. +5. **Expected:** Account B sees the game in spectator mode (the slot was filled by C between B's two fetches). + +--- + +## Test 10: Completed Game Viewing + +**Tests:** Spectators can view finished games. + +1. Play a game to completion (checkmate, or have one player resign). +2. Verify both players see the result. +3. Open the game URL in an incognito window (not logged in). +4. **Expected:** Board shows the final position. Result is displayed (e.g., "White wins -- checkmate"). No interactive elements. "Spectating" label visible. + +--- + +## Quick Checks + +These don't need a full walkthrough but are worth verifying: + +- [ ] The color selector on `/play` visually highlights the selected option. +- [ ] "Copy" button on the waiting screen copies the correct URL and shows "Copied!" briefly. +- [ ] Connection indicator shows "Live" (green dot) when Jetstream is connected. +- [ ] The Bluesky post from Test 4 has a clickable @mention (not plain text). +- [ ] Navigating away from a game page and back doesn't create duplicate Jetstream connections (check console for `[jetstream] connecting` logs). +- [ ] An existing game where the owner was always White (pre-color-selection) still loads correctly. diff --git a/plans/phase-1-challenge-invite-flow.md b/plans/phase-1-challenge-invite-flow.md new file mode 100644 index 0000000..1735724 --- /dev/null +++ b/plans/phase-1-challenge-invite-flow.md @@ -0,0 +1,70 @@ +# Phase 1: Challenge & Invite Flow + +Status: **Implemented** (branch: `phase-1-challenge-invite-flow`) + +--- + +## What Was Implemented + +### 1a. Post-Based Challenges -- DONE + +"Post to Bluesky" button on the game waiting screen. Creates a `app.bsky.feed.post` in the challenger's repo with: +- Text mentioning the opponent: "I'm challenging @{handle} to a game of chess on checkmate.blue!" +- Mention facet with correct UTF-8 byte offsets +- Link facet for the game URL +- `app.bsky.embed.external` with title and description for the link card + +Only shows when a specific opponent is set (not for open games). Button disables after posting to prevent double-posts. + +**Files created:** +- `src/lib/bluesky.ts` -- `buildFacets()`, `postToBluesky()`, `composeChallengePost()` +- `tests/lib/bluesky.test.ts` -- 9 tests covering facet construction, byte offsets with unicode, handle edge cases + +**Files modified:** +- `src/routes/game/[did]/[rkey]/+page.svelte` -- added post button in waiting state + +### 1b. DM-Based Challenges -- DONE (simplified) + +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. + +**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. + +**Files modified:** +- `src/routes/game/[did]/[rkey]/+page.svelte` -- added DM button in waiting state + +### 1c. Color Selection -- DONE + +White/Black/Random segmented control on the `/play` form. Game page logic fully decoupled from record ownership: + +- Owner creates canonical record regardless of their color +- `white`/`black` fields determine color, independent of who owns the record +- Non-owner creates child record with `parentGameUri` regardless of their color +- Jetstream, record pairing, and join logic all work for either color assignment +- `createGame` now accepts optional `white` param (was required) + +**Files modified:** +- `src/routes/play/+page.svelte` -- color selector UI, `resolveColors()` helper +- `src/routes/game/[did]/[rkey]/+page.svelte` -- `loadGame()` rewritten for color-agnostic logic, `joinAsBlack` renamed to `joinGame`, `waitForOpponent` handles either color +- `src/lib/atproto.ts` -- `white` param made optional in `createGame` +- `src/lib/types.ts` -- `challengerColor` added to `ChallengeRecord` +- `lexicons/blue.checkmate.challenge.json` -- `challengerColor` field added + +### Also: Check-Before-Join Guard + +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. + +--- + +## What Was NOT Implemented + +- **Editable post text** -- the Bluesky post uses a fixed template. An editable textarea (planned for Phase 5's game result sharing) was deferred. +- **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. +- **API-driven DM sending** -- requires `transition:chat.bsky` scope. Deferred unless there's a strong reason to broaden permissions. + +--- + +## Decisions Made During Implementation + +- **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. +- **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. +- **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. diff --git a/plans/phase-2-shareability-branding.md b/plans/phase-2-shareability-branding.md new file mode 100644 index 0000000..9899aa5 --- /dev/null +++ b/plans/phase-2-shareability-branding.md @@ -0,0 +1,212 @@ +# Phase 2: Shareability & Branding + +Priority: **High** + +## Problem + +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. + +## Current State + +- `app.html` has a plain `
Loading games...
+ {:else} + {#if activeGames.length > 0} +Shared!
+ {/if} +{/if} +``` + +**State variables:** + +```typescript +let shareText = $state(''); +let sharing = $state(false); +let shared = $state(false); +let dismissShare = $state(false); +``` + +**Initialize `shareText`** when the game result is detected: + +```typescript +$effect(() => { + if (game.result && !shareText) { + shareText = composeGameResultPost( + game.myColor, + game.result, + opponentHandle ?? 'opponent', + game.moveCount, + ); + } +}); +``` + +### Post Creation + +```typescript +async function shareResult() { + if (!auth.agent || !shareText.trim()) return; + sharing = true; + + const opponentDid = game.myColor === 'white' ? game.blackDid : game.whiteDid; + const gameUrl = `https://checkmate.blue/game/${ownerDid}/${rkey}`; + + // Build known handles map for facet detection + const handles = new Map