Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

checkmate.blue — Technical Specification v2#

Overview#

checkmate.blue is a federated chess platform built on the ATmosphere. Players authenticate with their ATmosphere account, play real-time 1v1 chess with game state stored as AT Protocol 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#

{
  "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.

{
  "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", "agreement", "stalemate", "insufficient", "repetition", "fifty_moves", "abandonment"]
          },
          "lastMoveAt": {
            "type": "string",
            "format": "datetime",
            "description": "Timestamp of the most recent move"
          },
          "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"
          },
          "variant": {
            "type": "string",
            "knownValues": ["standard", "really-bad-chess", "chess960"],
            "description": "Chess variant for this game. Omit for standard chess."
          },
          "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.

{
  "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"]
          },
          "variant": {
            "type": "string",
            "knownValues": ["standard", "really-bad-chess", "chess960"],
            "description": "Chess variant for this challenge. Omit for standard chess."
          }
        }
      }
    }
  }
}

Authentication#

Uses @atproto/oauth-client-browser for fully client-side ATmosphere OAuth. Sessions are stored in the browser's IndexedDB -- no server-side session management needed.

Setup#

// 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 AT Protocol 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" and enters their ATmosphere handle
  2. oauthClient.signIn(handle) redirects to their PDS for authorization
  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 AT Protocol operations
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, this is handled as a prerendered server route.


Game Flow#

1. Create Challenge#

Player A creates a challenge record in their own repo:

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.

// 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 AT Protocol 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 AT Protocol firehose over WebSocket. The client connects directly to Jetstream and filters for the opponent's record updates.

Connection#

// 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:

{
  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:

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.

// 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:

// 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" button (ATmosphere OAuth)
  • 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:

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   # ATmosphere sign-in
│   │   └── 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.

#checkmate wordmark with blue hash symbol. The # is the chess notation for checkmate and doubles as a hashtag. Path-based SVG favicon with no font dependency.

Color Palette#

:root {
  --bg-primary: #0B0F14;
  --bg-secondary: #1a2332;
  --bg-board: #2a3a4a;
  --accent-blue: #1d9bf0;
  --accent-blue-hover: #1a8cd8;
  --text-primary: #e6edf3;
  --text-secondary: #8b9098;
  --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.
  • ATmosphere ecosystem. This app works with any ATmosphere account (any PDS), not just Bluesky. The AT Protocol is the underlying protocol; the ATmosphere is the ecosystem of apps and services built on it.

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:

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
  • Tournament system
  • Full chess clocks / time controls
  • Anti-cheat
  • Custom PDS or relay
  • Chat / messaging
  • Custom board themes / piece sets
  • Move timer / timeout enforcement
  • Game search / filtering beyond Constellation queries