extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.

Add winner avatar display and opponent invite feature

Winner Display:
- Add winner field to database schema
- Display winner's avatar in archive cards instead of "completed" badge
- Emphasize winner's name in player list with bold styling
- Fetch user profiles for avatars
- Show trophy emoji placeholder if no avatar available

Opponent Invite:
- Add opponent handle input field to create game form
- Resolve Bluesky handle to DID on server-side
- Create game directly with opponent (status=active) instead of waiting room
- Add validation (handle not found, can't play self)
- Update button text based on whether opponent is specified

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

+139 -46
+4 -24
src/lib/server/db.ts
··· 59 59 status TEXT NOT NULL CHECK(status IN ('waiting', 'active', 'completed')), 60 60 action_count INTEGER NOT NULL DEFAULT 0, 61 61 last_action_type TEXT, 62 + winner TEXT, 62 63 created_at TEXT NOT NULL, 63 64 updated_at TEXT NOT NULL 64 65 ) ··· 107 108 `); 108 109 } 109 110 110 - // Drop old score/winner columns by recreating the table if they exist 111 - if (columnNames.includes('winner')) { 111 + // Add winner column if it doesn't exist (for completed games) 112 + if (!columnNames.includes('winner')) { 112 113 sqlite.exec(` 113 - CREATE TABLE IF NOT EXISTS games_new ( 114 - id TEXT PRIMARY KEY, 115 - rkey TEXT NOT NULL, 116 - player_one TEXT NOT NULL, 117 - player_two TEXT, 118 - board_size INTEGER NOT NULL DEFAULT 19, 119 - status TEXT NOT NULL CHECK(status IN ('waiting', 'active', 'completed')), 120 - action_count INTEGER NOT NULL DEFAULT 0, 121 - last_action_type TEXT, 122 - created_at TEXT NOT NULL, 123 - updated_at TEXT NOT NULL 124 - ); 125 - INSERT INTO games_new (id, rkey, player_one, player_two, board_size, status, action_count, last_action_type, created_at, updated_at) 126 - SELECT id, rkey, player_one, player_two, board_size, status, 127 - COALESCE(action_count, 0), last_action_type, created_at, updated_at 128 - FROM games; 129 - DROP TABLE games; 130 - ALTER TABLE games_new RENAME TO games; 131 - CREATE INDEX IF NOT EXISTS idx_games_status ON games(status); 132 - CREATE INDEX IF NOT EXISTS idx_games_player_one ON games(player_one); 133 - CREATE INDEX IF NOT EXISTS idx_games_player_two ON games(player_two); 134 - CREATE INDEX IF NOT EXISTS idx_games_rkey ON games(rkey); 114 + ALTER TABLE games ADD COLUMN winner TEXT; 135 115 `); 136 116 } 137 117 }
+1 -1
src/routes/+page.server.ts
··· 24 24 // Fetch completed games from last 7 days 25 25 const completedGames = await db 26 26 .selectFrom('games') 27 - .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count']) 27 + .select(['rkey', 'id', 'player_one', 'player_two', 'board_size', 'status', 'created_at', 'updated_at', 'last_action_type', 'action_count', 'winner']) 28 28 .where('status', '=', 'completed') 29 29 .where('updated_at', '>=', sevenDaysAgo) 30 30 .orderBy('updated_at', 'desc')
+92 -16
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import type { PageData } from './$types'; 3 3 import { onMount } from 'svelte'; 4 - import { resolveDidToHandle, fetchMoveCount, fetchCloudGoProfile } from '$lib/atproto-client'; 4 + import { resolveDidToHandle, fetchMoveCount, fetchCloudGoProfile, fetchUserProfile, type UserProfile } from '$lib/atproto-client'; 5 5 import type { ProfileRecord } from '$lib/types'; 6 6 import { formatElapsedTime, isStale } from '$lib/time-utils'; 7 7 ··· 11 11 let isLoggingIn = $state(false); 12 12 let isCreatingGame = $state(false); 13 13 let boardSize = $state(19); 14 + let opponentHandle = $state(''); 14 15 let spectating = $state(false); 15 16 let moveCounts = $state<Record<string, number | null>>({}); 16 17 let handles = $state<Record<string, string>>({}); 17 18 let playerStatuses = $state<Record<string, ProfileRecord | null>>({}); 19 + let userProfiles = $state<Record<string, UserProfile | null>>({}); 18 20 let sessionHandle = $state<string | null>(null); 19 21 let showMyGamesOnly = $state(false); 20 22 let showMyTurnOnly = $state(false); ··· 113 115 if (game.player_two) dids.add(game.player_two); 114 116 } 115 117 116 - // Resolve handles and fetch Cloud Go profiles 118 + // Resolve handles and fetch Cloud Go profiles and user profiles (for avatars) 117 119 for (const did of dids) { 118 120 resolveDidToHandle(did).then((h) => { 119 121 handles = { ...handles, [did]: h }; ··· 121 123 122 124 fetchCloudGoProfile(did).then((p) => { 123 125 playerStatuses = { ...playerStatuses, [did]: p }; 126 + }); 127 + 128 + fetchUserProfile(did).then((p) => { 129 + userProfiles = { ...userProfiles, [did]: p }; 124 130 }); 125 131 } 126 132 ··· 169 175 async function createGame() { 170 176 isCreatingGame = true; 171 177 try { 178 + const body: { boardSize: number; opponentHandle?: string } = { boardSize }; 179 + 180 + // If opponent handle is provided, include it 181 + if (opponentHandle.trim()) { 182 + body.opponentHandle = opponentHandle.trim(); 183 + } 184 + 172 185 const response = await fetch('/api/games', { 173 186 method: 'POST', 174 187 headers: { 'Content-Type': 'application/json' }, 175 - body: JSON.stringify({ boardSize }), 188 + body: JSON.stringify(body), 176 189 }); 177 190 178 191 const result = await response.json(); 179 192 if (result.gameId) { 180 193 window.location.href = `/game/${result.gameId}`; 194 + } else if (result.error) { 195 + alert(result.error); 181 196 } 182 197 } catch (err) { 183 198 console.error('Failed to create game:', err); ··· 351 366 <div class="archive-grid"> 352 367 {#each paginatedArchivedGames as game} 353 368 {@const resignedBy = getResignedBy(game)} 369 + {@const winnerDid = game.winner} 370 + {@const winnerProfile = winnerDid ? userProfiles[winnerDid] : null} 354 371 <a href="/game/{game.rkey}" class="archive-card"> 355 372 <div class="archive-card-header"> 356 373 <div class="game-title">{game.title}</div> ··· 358 375 <span class="game-status game-status-cancelled"> 359 376 {resignedBy === 'black' ? '⚫' : '⚪'} resigned 360 377 </span> 378 + {:else if winnerDid && winnerProfile} 379 + <div class="winner-avatar"> 380 + {#if winnerProfile.avatar} 381 + <img src={winnerProfile.avatar} alt="Winner" /> 382 + {:else} 383 + <div class="winner-avatar-placeholder">🏆</div> 384 + {/if} 385 + </div> 361 386 {:else} 362 387 <span class="game-status game-status-completed">completed</span> 363 388 {/if} ··· 370 395 </div> 371 396 </div> 372 397 <div class="archive-card-players"> 373 - <span>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 398 + <span class:winner={game.player_one === winnerDid}>{handles[game.player_one] || game.player_one.slice(0, 15)}</span> 374 399 {#if game.player_two} 375 400 <span class="vs">vs</span> 376 - <span>{handles[game.player_two] || game.player_two.slice(0, 15)}</span> 401 + <span class:winner={game.player_two === winnerDid}>{handles[game.player_two] || game.player_two.slice(0, 15)}</span> 377 402 {/if} 378 403 </div> 379 404 </a> ··· 409 434 <div class="card"> 410 435 <h2>Create New Game</h2> 411 436 <div class="create-game-form"> 412 - <label> 413 - Board Size: 414 - <select bind:value={boardSize} class="select"> 415 - <option value={5}>5x5</option> 416 - <option value={7}>7x7</option> 417 - <option value={9}>9x9</option> 418 - <option value={13}>13x13</option> 419 - <option value={19}>19x19</option> 420 - </select> 421 - </label> 437 + <div class="form-row"> 438 + <label> 439 + Board Size: 440 + <select bind:value={boardSize} class="select"> 441 + <option value={5}>5x5</option> 442 + <option value={7}>7x7</option> 443 + <option value={9}>9x9</option> 444 + <option value={13}>13x13</option> 445 + <option value={19}>19x19</option> 446 + </select> 447 + </label> 448 + <label> 449 + Opponent (optional): 450 + <input 451 + type="text" 452 + bind:value={opponentHandle} 453 + placeholder="@handle.bsky.social" 454 + class="input" 455 + /> 456 + </label> 457 + </div> 422 458 <button 423 459 onclick={createGame} 424 460 disabled={isCreatingGame} 425 461 class="button button-primary" 426 462 > 427 - {isCreatingGame ? 'Creating...' : 'Create Game'} 463 + {isCreatingGame ? 'Creating...' : (opponentHandle ? 'Invite to Game' : 'Create Game')} 428 464 </button> 429 465 </div> 430 466 </div> ··· 731 767 732 768 .create-game-form { 733 769 display: flex; 770 + flex-direction: column; 771 + gap: 1rem; 772 + } 773 + 774 + .form-row { 775 + display: flex; 734 776 gap: 1rem; 735 777 align-items: flex-end; 778 + } 779 + 780 + .form-row label { 781 + flex: 1; 736 782 } 737 783 738 784 .input, .select { ··· 1296 1342 .archive-card-players .vs { 1297 1343 color: var(--sky-gray-light); 1298 1344 font-style: italic; 1345 + } 1346 + 1347 + .archive-card-players .winner { 1348 + font-weight: 700; 1349 + color: var(--sky-slate-dark); 1350 + } 1351 + 1352 + .winner-avatar { 1353 + width: 32px; 1354 + height: 32px; 1355 + border-radius: 50%; 1356 + overflow: hidden; 1357 + border: 2px solid var(--sky-apricot); 1358 + flex-shrink: 0; 1359 + } 1360 + 1361 + .winner-avatar img { 1362 + width: 100%; 1363 + height: 100%; 1364 + object-fit: cover; 1365 + } 1366 + 1367 + .winner-avatar-placeholder { 1368 + width: 100%; 1369 + height: 100%; 1370 + display: flex; 1371 + align-items: center; 1372 + justify-content: center; 1373 + background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-apricot) 100%); 1374 + font-size: 1rem; 1299 1375 } 1300 1376 1301 1377 .pagination {
+42 -5
src/routes/api/games/+server.ts
··· 10 10 return tid; 11 11 } 12 12 13 + async function resolveHandleToDid(handle: string): Promise<string | null> { 14 + try { 15 + // Remove @ prefix if present 16 + const cleanHandle = handle.startsWith('@') ? handle.slice(1) : handle; 17 + 18 + const response = await fetch( 19 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(cleanHandle)}` 20 + ); 21 + 22 + if (!response.ok) return null; 23 + 24 + const data = await response.json(); 25 + return data.did || null; 26 + } catch (err) { 27 + console.error('Failed to resolve handle:', err); 28 + return null; 29 + } 30 + } 31 + 13 32 export const POST: RequestHandler = async (event) => { 14 33 const session = await getSession(event); 15 34 ··· 17 36 throw error(401, 'Not authenticated'); 18 37 } 19 38 20 - const { boardSize = 19 } = await event.request.json(); 39 + const { boardSize = 19, opponentHandle } = await event.request.json(); 21 40 22 41 if (![5, 7, 9, 13, 19].includes(boardSize)) { 23 42 throw error(400, 'Invalid board size. Supported: 5x5, 7x7, 9x9, 13x13, 19x19'); ··· 29 48 throw error(401, 'Failed to get authenticated agent'); 30 49 } 31 50 51 + // Resolve opponent handle if provided 52 + let opponentDid: string | null = null; 53 + if (opponentHandle) { 54 + opponentDid = await resolveHandleToDid(opponentHandle); 55 + if (!opponentDid) { 56 + return json({ error: 'Could not find user with that handle. Please check the handle and try again.' }, { status: 400 }); 57 + } 58 + if (opponentDid === session.did) { 59 + return json({ error: 'You cannot play against yourself!' }, { status: 400 }); 60 + } 61 + } 62 + 32 63 const rkey = generateTid(); 33 64 const now = new Date().toISOString(); 34 65 35 66 // Create game record in AT Protocol 36 - const record = { 67 + const record: any = { 37 68 $type: 'boo.sky.go.game', 38 69 playerOne: session.did, 39 70 boardSize, 40 - status: 'waiting', 71 + status: opponentDid ? 'active' : 'waiting', 41 72 createdAt: now, 42 73 }; 74 + 75 + // Add opponent if provided 76 + if (opponentDid) { 77 + record.playerTwo = opponentDid; 78 + } 43 79 44 80 // Publish to AT Protocol 45 81 const result = await (agent as any).post('com.atproto.repo.createRecord', { ··· 65 101 id: uri, 66 102 rkey, 67 103 player_one: session.did, 68 - player_two: null, 104 + player_two: opponentDid, 69 105 board_size: boardSize, 70 - status: 'waiting', 106 + status: opponentDid ? 'active' : 'waiting', 71 107 action_count: 0, 72 108 last_action_type: null, 109 + winner: null, 73 110 created_at: now, 74 111 updated_at: now, 75 112 })