Chess on the ATmosphere checkmate.blue
chess
13
fork

Configure Feed

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

Add homepage game discovery and live feed

Redesign homepage with rich game cards showing player handles, status,
move count, and relative time. User's games fetched from Constellation
and deduplicated (challenger's record only. Split into active and
completed sections.

Live feed connects to Jetstream with 2-hour historical cursor to show
active games across the network, visible to all visitors.

Add standalone firehose logger script (npm run firehose) that writes
all game and challenge events to data/firehose.jsonl for future stats.
EOF
)

authored by

Scott Hadfield and committed by tangled.org ecfc9251 f5c7b147

+360 -50
+3
.gitignore
··· 21 21 # Vite 22 22 vite.config.js.timestamp-* 23 23 vite.config.ts.timestamp-* 24 + 25 + # Firehose logs 26 + /data
+2 -1
package.json
··· 11 11 "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 12 12 "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 13 13 "test": "vitest run", 14 - "test:watch": "vitest" 14 + "test:watch": "vitest", 15 + "firehose": "npx tsx scripts/firehose-logger.ts" 15 16 }, 16 17 "devDependencies": { 17 18 "@sveltejs/adapter-static": "^3.0.10",
+70
scripts/firehose-logger.ts
··· 1 + import { createWriteStream } from 'fs'; 2 + import { join, dirname } from 'path'; 3 + import { fileURLToPath } from 'url'; 4 + import { mkdir } from 'fs/promises'; 5 + 6 + const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; 7 + const COLLECTIONS = ['blue.checkmate.game', 'blue.checkmate.challenge']; 8 + 9 + const __dirname = dirname(fileURLToPath(import.meta.url)); 10 + const logDir = join(__dirname, '..', 'data'); 11 + const logFile = join(logDir, 'firehose.jsonl'); 12 + 13 + await mkdir(logDir, { recursive: true }); 14 + const stream = createWriteStream(logFile, { flags: 'a' }); 15 + 16 + let cursor: number | null = null; 17 + let reconnectAttempts = 0; 18 + 19 + function connect() { 20 + const url = new URL(JETSTREAM_URL); 21 + for (const c of COLLECTIONS) { 22 + url.searchParams.append('wantedCollections', c); 23 + } 24 + if (cursor) url.searchParams.set('cursor', String(cursor)); 25 + 26 + console.log(`[firehose] connecting...${cursor ? ` (cursor: ${cursor})` : ''}`); 27 + const ws = new WebSocket(url.toString()); 28 + 29 + ws.onopen = () => { 30 + console.log('[firehose] connected'); 31 + reconnectAttempts = 0; 32 + }; 33 + 34 + ws.onmessage = (event) => { 35 + const data = JSON.parse(String(event.data)); 36 + if (data.time_us) cursor = data.time_us; 37 + if (data.kind !== 'commit') return; 38 + 39 + const line = JSON.stringify({ 40 + time: new Date().toISOString(), 41 + time_us: data.time_us, 42 + did: data.did, 43 + operation: data.commit.operation, 44 + collection: data.commit.collection, 45 + rkey: data.commit.rkey, 46 + record: data.commit.record ?? null, 47 + }); 48 + 49 + stream.write(line + '\n'); 50 + console.log( 51 + `[firehose] ${data.commit.operation} ${data.commit.collection} ${data.did.slice(0, 24)}...` 52 + ); 53 + }; 54 + 55 + ws.onclose = (event: CloseEvent) => { 56 + console.log(`[firehose] disconnected: ${event.code} ${event.reason}`); 57 + const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30_000); 58 + reconnectAttempts++; 59 + console.log(`[firehose] reconnecting in ${delay}ms...`); 60 + setTimeout(connect, delay); 61 + }; 62 + 63 + ws.onerror = () => { 64 + ws.close(); 65 + }; 66 + } 67 + 68 + console.log(`[firehose] logging to ${logFile}`); 69 + console.log(`[firehose] watching: ${COLLECTIONS.join(', ')}`); 70 + connect();
+90
src/lib/components/GameCard.svelte
··· 1 + <script lang="ts"> 2 + import { Chess } from 'chess.js'; 3 + import { resolveHandle } from '$lib/microcosm'; 4 + import type { GameRecord } from '$lib/types'; 5 + 6 + type Props = { 7 + did: string; 8 + rkey: string; 9 + record: GameRecord; 10 + timestamp?: number; 11 + }; 12 + 13 + let { did, rkey, record, timestamp }: Props = $props(); 14 + 15 + let whiteHandle = $state('...'); 16 + let blackHandle = $state('...'); 17 + 18 + let pgnInfo = $derived.by(() => { 19 + const chess = new Chess(); 20 + if (record.pgn) { 21 + try { chess.loadPgn(record.pgn); } catch { /* empty pgn is fine */ } 22 + } 23 + return { 24 + moveCount: Math.ceil(chess.history().length / 2), 25 + turn: chess.turn() === 'w' ? 'White' : 'Black', 26 + }; 27 + }); 28 + 29 + $effect(() => { 30 + if (record.white) resolveHandle(record.white).then((h) => (whiteHandle = h)); 31 + else whiteHandle = '?'; 32 + if (record.black) resolveHandle(record.black).then((h) => (blackHandle = h)); 33 + else blackHandle = '?'; 34 + }); 35 + 36 + function statusText(): string { 37 + switch (record.status) { 38 + case 'waiting': 39 + return 'Waiting for opponent'; 40 + case 'active': 41 + return `${pgnInfo.turn} to move \u2013 ${pgnInfo.moveCount} move${pgnInfo.moveCount !== 1 ? 's' : ''}`; 42 + case 'completed': 43 + return resultText(); 44 + case 'abandoned': 45 + return 'Abandoned'; 46 + default: 47 + return record.status; 48 + } 49 + } 50 + 51 + function resultText(): string { 52 + const reason = record.resultReason?.replace('_', ' '); 53 + if (record.result === '1-0') return `White wins${reason ? ` by ${reason}` : ''}`; 54 + if (record.result === '0-1') return `Black wins${reason ? ` by ${reason}` : ''}`; 55 + if (record.result === '1/2-1/2') return `Draw${reason ? ` \u2013 ${reason}` : ''}`; 56 + return 'Completed'; 57 + } 58 + 59 + function timeAgo(ts?: number | string): string { 60 + if (!ts) return ''; 61 + const ms = typeof ts === 'number' ? ts : new Date(ts).getTime(); 62 + const seconds = Math.floor((Date.now() - ms) / 1000); 63 + if (seconds < 60) return 'just now'; 64 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 65 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 66 + return `${Math.floor(seconds / 86400)}d ago`; 67 + } 68 + </script> 69 + 70 + <a 71 + href="/game/{did}/{rkey}" 72 + class="block rounded-lg border border-border bg-bg-secondary px-4 py-3 transition-colors hover:border-accent-blue" 73 + > 74 + <div class="flex items-center justify-between"> 75 + <div class="flex items-center gap-2 text-sm"> 76 + <span class="font-medium">{whiteHandle}</span> 77 + <span class="text-text-secondary">vs</span> 78 + <span class="font-medium">{blackHandle}</span> 79 + </div> 80 + {#if record.status === 'active'} 81 + <span class="rounded-full bg-success/20 px-2 py-0.5 text-xs text-success">Live</span> 82 + {:else if record.status === 'waiting'} 83 + <span class="rounded-full bg-warning/20 px-2 py-0.5 text-xs text-warning">Waiting</span> 84 + {/if} 85 + </div> 86 + <div class="mt-1 flex items-center justify-between text-xs text-text-secondary"> 87 + <span>{statusText()}</span> 88 + <span>{timeAgo(timestamp ?? record.createdAt)}</span> 89 + </div> 90 + </a>
+11
src/lib/microcosm.ts
··· 33 33 if (!response.ok) return null; 34 34 return response.json(); 35 35 } 36 + 37 + const handleCache = new Map<string, string>(); 38 + 39 + export async function resolveHandle(did: string): Promise<string> { 40 + const cached = handleCache.get(did); 41 + if (cached) return cached; 42 + const profile = await resolveIdentity(did); 43 + const handle = profile?.handle ?? did; 44 + handleCache.set(did, handle); 45 + return handle; 46 + }
+184 -49
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { auth } from '$lib/stores/auth.svelte'; 4 - import { findGamesForPlayer, type ConstellationLink } from '$lib/microcosm'; 4 + import { findGamesForPlayer } from '$lib/microcosm'; 5 + import { getGamePublic, COLLECTIONS } from '$lib/atproto'; 6 + import type { GameRecord } from '$lib/types'; 7 + import GameCard from '$lib/components/GameCard.svelte'; 5 8 import LoginButton from '$lib/components/LoginButton.svelte'; 6 9 7 - let games: ConstellationLink[] = $state([]); 8 - let loadingGames = $state(false); 10 + interface GameEntry { 11 + did: string; 12 + rkey: string; 13 + record: GameRecord; 14 + timestamp: number; 15 + } 16 + 17 + let userGames: GameEntry[] = $state([]); 18 + let loadingUserGames = $state(false); 19 + let liveGames: GameEntry[] = $state([]); 20 + let liveConnected = $state(false); 21 + 22 + const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; 23 + const liveGameMap = new Map<string, GameEntry>(); 24 + let ws: WebSocket | null = null; 25 + let destroyed = false; 26 + let cursor = (Date.now() - 7_200_000) * 1000; 27 + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; 9 28 10 29 $effect(() => { 11 - if (auth.isLoggedIn && auth.did) { 12 - loadGames(auth.did); 13 - } 30 + if (auth.isLoggedIn && auth.did) loadUserGames(auth.did); 31 + }); 32 + 33 + onMount(() => { 34 + connectLiveFeed(); 35 + return () => { 36 + destroyed = true; 37 + ws?.close(); 38 + if (reconnectTimer) clearTimeout(reconnectTimer); 39 + }; 14 40 }); 15 41 16 - async function loadGames(did: string) { 17 - loadingGames = true; 42 + function parseAtUri(uri: string): { did: string; rkey: string } | null { 43 + const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 44 + if (!match) return null; 45 + return { did: match[1], rkey: match[2] }; 46 + } 47 + 48 + async function loadUserGames(did: string) { 49 + loadingUserGames = true; 18 50 try { 19 - games = await findGamesForPlayer(did); 51 + const links = await findGamesForPlayer(did); 52 + const results = await Promise.all( 53 + links.slice(0, 20).map(async (link) => { 54 + const parsed = parseAtUri(link.uri); 55 + if (!parsed) return null; 56 + const record = await getGamePublic(parsed.did, parsed.rkey); 57 + if (!record || record.parentGameUri) return null; 58 + return { 59 + did: parsed.did, 60 + rkey: parsed.rkey, 61 + record, 62 + timestamp: new Date(record.createdAt).getTime(), 63 + }; 64 + }) 65 + ); 66 + userGames = results 67 + .filter((g): g is GameEntry => g !== null) 68 + .sort((a, b) => b.timestamp - a.timestamp); 20 69 } catch { 21 - games = []; 70 + userGames = []; 22 71 } 23 - loadingGames = false; 72 + loadingUserGames = false; 24 73 } 25 74 26 - function parseAtUri(uri: string): { did: string; rkey: string } | null { 27 - const match = uri.match(/^at:\/\/([^/]+)\/[^/]+\/(.+)$/); 28 - if (!match) return null; 29 - return { did: match[1], rkey: match[2] }; 75 + function connectLiveFeed() { 76 + if (destroyed) return; 77 + 78 + const url = new URL(JETSTREAM_URL); 79 + url.searchParams.set('wantedCollections', COLLECTIONS.game); 80 + url.searchParams.set('cursor', String(cursor)); 81 + 82 + ws = new WebSocket(url.toString()); 83 + 84 + ws.onopen = () => { 85 + liveConnected = true; 86 + }; 87 + 88 + ws.onmessage = (event) => { 89 + const data = JSON.parse(event.data); 90 + if (data.time_us) cursor = data.time_us; 91 + if (data.kind !== 'commit') return; 92 + 93 + const { did, commit } = data; 94 + if (commit.collection !== COLLECTIONS.game) return; 95 + 96 + const key = `${did}/${commit.rkey}`; 97 + 98 + if (commit.operation === 'delete') { 99 + liveGameMap.delete(key); 100 + } else { 101 + const record = commit.record as GameRecord; 102 + if (!record || record.parentGameUri) return; 103 + if (record.status === 'active' || record.status === 'waiting') { 104 + liveGameMap.set(key, { 105 + did, 106 + rkey: commit.rkey, 107 + record, 108 + timestamp: (data.time_us ?? Date.now() * 1000) / 1000, 109 + }); 110 + } else { 111 + liveGameMap.delete(key); 112 + } 113 + } 114 + 115 + liveGames = [...liveGameMap.values()].sort((a, b) => b.timestamp - a.timestamp); 116 + }; 117 + 118 + ws.onclose = () => { 119 + liveConnected = false; 120 + if (!destroyed) { 121 + reconnectTimer = setTimeout(connectLiveFeed, 5000); 122 + } 123 + }; 124 + 125 + ws.onerror = () => ws?.close(); 30 126 } 127 + 128 + let activeUserGames = $derived( 129 + userGames.filter((g) => g.record.status === 'active' || g.record.status === 'waiting') 130 + ); 131 + let completedUserGames = $derived( 132 + userGames.filter((g) => g.record.status === 'completed') 133 + ); 31 134 </script> 32 135 33 - <div class="flex min-h-screen flex-col items-center justify-center gap-6 p-4"> 34 - <div class="text-center"> 136 + <div class="mx-auto max-w-2xl px-4 py-8"> 137 + <div class="mb-8 text-center"> 35 138 <h1 class="text-4xl font-bold text-accent-blue">checkmate.blue</h1> 36 139 <p class="mt-2 text-text-secondary">Chess on the Atmosphere</p> 37 - </div> 38 - 39 - {#if auth.isLoggedIn} 40 - <div class="flex flex-col items-center gap-4"> 41 - <p class="text-lg"> 42 - Signed in as <span class="font-semibold text-accent-blue">{auth.handle}</span> 43 - </p> 44 - <div class="flex gap-3"> 140 + {#if auth.isLoggedIn} 141 + <div class="mt-4 flex justify-center gap-3"> 45 142 <a 46 143 href="/play" 47 144 class="rounded-lg bg-accent-blue px-6 py-2 font-semibold text-white transition-colors hover:bg-accent-blue-hover" ··· 55 152 Sign out 56 153 </button> 57 154 </div> 58 - </div> 59 - 60 - {#if loadingGames} 61 - <p class="text-sm text-text-secondary">Loading games...</p> 62 - {:else if games.length > 0} 63 - <div class="w-full max-w-md"> 64 - <h2 class="mb-3 text-lg font-semibold">Your Games</h2> 65 - <div class="flex flex-col gap-2"> 66 - {#each games as game} 67 - {@const parsed = parseAtUri(game.uri)} 68 - {#if parsed} 69 - <a 70 - href="/game/{parsed.did}/{parsed.rkey}" 71 - class="rounded-lg border border-border bg-bg-secondary px-4 py-3 transition-colors hover:border-accent-blue" 72 - > 73 - <span class="font-mono text-sm text-text-secondary">{parsed.rkey}</span> 74 - </a> 75 - {/if} 76 - {/each} 77 - </div> 155 + {:else} 156 + <div class="mx-auto mt-4 w-full max-w-sm"> 157 + <LoginButton /> 78 158 </div> 79 159 {/if} 80 - {:else} 81 - <div class="w-full max-w-sm"> 82 - <LoginButton /> 83 - </div> 160 + </div> 161 + 162 + {#if auth.isLoggedIn} 163 + {#if loadingUserGames} 164 + <p class="mb-8 text-sm text-text-secondary">Loading your games...</p> 165 + {:else} 166 + {#if activeUserGames.length > 0} 167 + <section class="mb-8"> 168 + <h2 class="mb-3 text-lg font-semibold">Your Active Games</h2> 169 + <div class="flex flex-col gap-2"> 170 + {#each activeUserGames as game} 171 + <GameCard did={game.did} rkey={game.rkey} record={game.record} /> 172 + {/each} 173 + </div> 174 + </section> 175 + {/if} 176 + 177 + {#if completedUserGames.length > 0} 178 + <section class="mb-8"> 179 + <h2 class="mb-3 text-lg font-semibold">Completed</h2> 180 + <div class="flex flex-col gap-2"> 181 + {#each completedUserGames.slice(0, 5) as game} 182 + <GameCard did={game.did} rkey={game.rkey} record={game.record} /> 183 + {/each} 184 + </div> 185 + </section> 186 + {/if} 187 + 188 + {#if userGames.length === 0} 189 + <p class="mb-8 text-center text-sm text-text-secondary"> 190 + No games yet. <a href="/play" class="text-accent-blue hover:underline">Start one!</a> 191 + </p> 192 + {/if} 193 + {/if} 84 194 {/if} 195 + 196 + <section> 197 + <div class="mb-3 flex items-center gap-2"> 198 + <h2 class="text-lg font-semibold">Live on the Atmosphere</h2> 199 + {#if liveConnected} 200 + <span class="h-2 w-2 rounded-full bg-success"></span> 201 + {/if} 202 + </div> 203 + {#if liveGames.length > 0} 204 + <div class="flex flex-col gap-2"> 205 + {#each liveGames.slice(0, 20) as game} 206 + <GameCard 207 + did={game.did} 208 + rkey={game.rkey} 209 + record={game.record} 210 + timestamp={game.timestamp} 211 + /> 212 + {/each} 213 + </div> 214 + {:else} 215 + <p class="text-sm text-text-secondary"> 216 + {liveConnected ? 'No active games right now. Be the first!' : 'Connecting to live feed...'} 217 + </p> 218 + {/if} 219 + </section> 85 220 </div>