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.
0
fork

Configure Feed

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

Replace tenuki mini boards with server-rendered SVGs

- Created shared board-svg.ts utility for generating board SVGs
- Added /api/games/[id]/board endpoint that returns SVG images
- Mini boards now use simple <img> tags pointing to the SVG endpoint
- SVGs are cached (30s for active games, 1hr for completed)
- Removed client-side MiniBoard.svelte component
- Refactored og-image to use shared buildBoardStateFromMoves

This approach is:
- Simpler (just img tags)
- More performant (server-side rendering, caching)
- Scalable (SVG format)
- Consistent with OG image styling

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

+247 -202
-92
src/lib/components/MiniBoard.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import tenuki from 'tenuki'; 4 - 5 - interface Props { 6 - boardSize?: number; 7 - moves?: Array<{ x: number; y: number; color: 'black' | 'white' }>; 8 - size?: number; 9 - } 10 - 11 - let { 12 - boardSize = 19, 13 - moves = [], 14 - size = 70 15 - }: Props = $props(); 16 - 17 - let boardElement: HTMLDivElement; 18 - let game: any = null; 19 - 20 - onMount(() => { 21 - if (!boardElement) return; 22 - 23 - // Add flat styling class for cleaner mini view 24 - boardElement.classList.add('tenuki-board-flat'); 25 - 26 - // Create a tenuki game for display only 27 - game = new tenuki.Game({ 28 - element: boardElement, 29 - boardSize, 30 - }); 31 - 32 - // Replay all moves to show current board state 33 - for (const move of moves) { 34 - // tenuki uses (y, x) coordinates 35 - game.playAt(move.y, move.x); 36 - } 37 - 38 - return () => { 39 - // Cleanup 40 - if (boardElement) { 41 - boardElement.innerHTML = ''; 42 - } 43 - game = null; 44 - }; 45 - }); 46 - </script> 47 - 48 - <div class="mini-board-container" style="width: {size}px; height: {size}px;"> 49 - <div bind:this={boardElement} class="mini-board"></div> 50 - </div> 51 - 52 - <style> 53 - .mini-board-container { 54 - flex-shrink: 0; 55 - overflow: hidden; 56 - border-radius: 4px; 57 - background: #DCB35C; 58 - } 59 - 60 - .mini-board { 61 - width: 100%; 62 - height: 100%; 63 - pointer-events: none; 64 - } 65 - 66 - /* Tenuki sizing overrides for mini display */ 67 - .mini-board :global(.tenuki-inner-container) { 68 - width: 100% !important; 69 - height: 100% !important; 70 - } 71 - 72 - .mini-board :global(.tenuki-zoom-container) { 73 - width: 100% !important; 74 - height: 100% !important; 75 - display: flex; 76 - align-items: center; 77 - justify-content: center; 78 - } 79 - 80 - .mini-board :global(svg) { 81 - max-width: 100% !important; 82 - max-height: 100% !important; 83 - width: auto !important; 84 - height: auto !important; 85 - } 86 - 87 - /* Hide zoom UI elements */ 88 - .mini-board :global(.cancel-zoom), 89 - .mini-board :global(.cancel-zoom-backdrop) { 90 - display: none !important; 91 - } 92 - </style>
+160
src/lib/server/board-svg.ts
··· 1 + /** 2 + * Generate an SVG representation of a Go board with stones. 3 + */ 4 + 5 + export interface BoardSvgOptions { 6 + size?: number; 7 + padding?: number; 8 + showLastMove?: boolean; 9 + backgroundColor?: string; 10 + } 11 + 12 + export function generateBoardSvg( 13 + boardSize: number, 14 + boardState: Array<Array<'black' | 'white' | null>>, 15 + lastMove: { x: number; y: number } | null = null, 16 + options: BoardSvgOptions = {} 17 + ): string { 18 + const { 19 + size: svgSize = 200, 20 + padding = 10, 21 + showLastMove = true, 22 + backgroundColor = '#DEB887', 23 + } = options; 24 + 25 + const innerSize = svgSize - padding * 2; 26 + const step = innerSize / (boardSize - 1); 27 + const stoneRadius = step * 0.45; 28 + 29 + // Generate unique IDs for gradients to avoid conflicts when multiple boards on page 30 + const gradientSuffix = Math.random().toString(36).slice(2, 8); 31 + 32 + let svg = `<svg width="${svgSize}" height="${svgSize}" viewBox="0 0 ${svgSize} ${svgSize}" xmlns="http://www.w3.org/2000/svg">`; 33 + 34 + // Defs for gradients 35 + svg += ` 36 + <defs> 37 + <linearGradient id="boardGradient-${gradientSuffix}" x1="0%" y1="0%" x2="100%" y2="100%"> 38 + <stop offset="0%" style="stop-color:#DEB887"/> 39 + <stop offset="100%" style="stop-color:#C4A067"/> 40 + </linearGradient> 41 + <radialGradient id="blackStone-${gradientSuffix}" cx="35%" cy="35%"> 42 + <stop offset="0%" style="stop-color:#4a4a4a"/> 43 + <stop offset="100%" style="stop-color:#1a1a1a"/> 44 + </radialGradient> 45 + <radialGradient id="whiteStone-${gradientSuffix}" cx="35%" cy="35%"> 46 + <stop offset="0%" style="stop-color:#ffffff"/> 47 + <stop offset="100%" style="stop-color:#d0d0d0"/> 48 + </radialGradient> 49 + </defs>`; 50 + 51 + // Board background 52 + svg += `<rect width="${svgSize}" height="${svgSize}" rx="4" fill="url(#boardGradient-${gradientSuffix})" stroke="#8B7355" stroke-width="1"/>`; 53 + 54 + // Grid lines 55 + for (let i = 0; i < boardSize; i++) { 56 + const pos = padding + i * step; 57 + // Horizontal lines 58 + svg += `<line x1="${padding}" y1="${pos}" x2="${svgSize - padding}" y2="${pos}" stroke="#5C4830" stroke-width="0.5" opacity="0.7"/>`; 59 + // Vertical lines 60 + svg += `<line x1="${pos}" y1="${padding}" x2="${pos}" y2="${svgSize - padding}" stroke="#5C4830" stroke-width="0.5" opacity="0.7"/>`; 61 + } 62 + 63 + // Star points (hoshi) 64 + const starPoints = getStarPoints(boardSize); 65 + const hoshiRadius = Math.max(1.5, step * 0.08); 66 + for (const [x, y] of starPoints) { 67 + const px = padding + x * step; 68 + const py = padding + y * step; 69 + svg += `<circle cx="${px}" cy="${py}" r="${hoshiRadius}" fill="#5C4830" opacity="0.8"/>`; 70 + } 71 + 72 + // Stones 73 + for (let y = 0; y < boardState.length; y++) { 74 + for (let x = 0; x < (boardState[y]?.length || 0); x++) { 75 + const stone = boardState[y][x]; 76 + if (stone) { 77 + const px = padding + x * step; 78 + const py = padding + y * step; 79 + const isLastMove = showLastMove && lastMove && lastMove.x === x && lastMove.y === y; 80 + 81 + if (stone === 'black') { 82 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius}" fill="url(#blackStone-${gradientSuffix})"/>`; 83 + if (isLastMove) { 84 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius * 0.35}" fill="none" stroke="#fff" stroke-width="1.5"/>`; 85 + } 86 + } else { 87 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius}" fill="url(#whiteStone-${gradientSuffix})" stroke="#aaa" stroke-width="0.5"/>`; 88 + if (isLastMove) { 89 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius * 0.35}" fill="none" stroke="#333" stroke-width="1.5"/>`; 90 + } 91 + } 92 + } 93 + } 94 + } 95 + 96 + svg += '</svg>'; 97 + return svg; 98 + } 99 + 100 + function getStarPoints(boardSize: number): Array<[number, number]> { 101 + if (boardSize === 9) { 102 + return [ 103 + [2, 2], [6, 2], 104 + [4, 4], 105 + [2, 6], [6, 6], 106 + ]; 107 + } else if (boardSize === 13) { 108 + return [ 109 + [3, 3], [9, 3], 110 + [6, 6], 111 + [3, 9], [9, 9], 112 + ]; 113 + } else if (boardSize === 19) { 114 + return [ 115 + [3, 3], [9, 3], [15, 3], 116 + [3, 9], [9, 9], [15, 9], 117 + [3, 15], [9, 15], [15, 15], 118 + ]; 119 + } 120 + return []; 121 + } 122 + 123 + /** 124 + * Build board state from moves using tenuki for proper capture handling. 125 + */ 126 + export async function buildBoardStateFromMoves( 127 + moves: Array<{ x: number; y: number; color: 'black' | 'white' }>, 128 + boardSize: number 129 + ): Promise<{ boardState: Array<Array<'black' | 'white' | null>>; lastMove: { x: number; y: number } | null }> { 130 + const tenuki = await import('tenuki'); 131 + const game = new tenuki.default.Game({ boardSize }); 132 + 133 + let lastMove: { x: number; y: number } | null = null; 134 + 135 + for (const move of moves) { 136 + try { 137 + game.playAt(move.y, move.x); 138 + lastMove = { x: move.x, y: move.y }; 139 + } catch { 140 + // Move might be invalid, skip 141 + } 142 + } 143 + 144 + // Extract board state 145 + const boardState: Array<Array<'black' | 'white' | null>> = Array.from( 146 + { length: boardSize }, 147 + () => Array.from({ length: boardSize }, () => null) 148 + ); 149 + 150 + for (let y = 0; y < boardSize; y++) { 151 + for (let x = 0; x < boardSize; x++) { 152 + const intersection = game.intersectionAt(y, x); 153 + if (intersection.value === 'black' || intersection.value === 'white') { 154 + boardState[y][x] = intersection.value; 155 + } 156 + } 157 + } 158 + 159 + return { boardState, lastMove }; 160 + }
+14 -81
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, fetchGameActionsFromPds } from '$lib/atproto-client'; 5 - import MiniBoard from '$lib/components/MiniBoard.svelte'; 6 - import type { MoveRecord } from '$lib/types'; 4 + import { resolveDidToHandle, fetchMoveCount } from '$lib/atproto-client'; 7 5 8 6 let { data }: { data: PageData } = $props(); 9 7 ··· 19 17 let showMyTurnOnly = $state(false); 20 18 let archivePage = $state(1); 21 19 const ARCHIVE_PAGE_SIZE = 6; 22 - 23 - // Store moves for mini board previews 24 - let gameMoves = $state<Record<string, Array<{ x: number; y: number; color: 'black' | 'white' }>>>({}); 25 - let loadingMoves = $state<Record<string, boolean>>({}); 26 20 27 21 // Helper to determine whose turn it is in a game 28 22 function getWhoseTurn(game: typeof data.games[0]): 'black' | 'white' { ··· 111 105 moveCounts = { ...moveCounts, [game.id]: count }; 112 106 }); 113 107 } 114 - 115 - // Fetch moves for active games (for mini board previews) 116 - const activeGames = data.games.filter(g => g.status === 'active'); 117 - for (const game of activeGames) { 118 - fetchMovesForGame(game); 119 - } 120 108 } 121 109 }); 122 110 ··· 189 177 alert('Failed to join game. Please try again.'); 190 178 } 191 179 } 192 - 193 - // Fetch moves for a game to display on mini board 194 - async function fetchMovesForGame(game: typeof data.games[0]) { 195 - if (loadingMoves[game.id] || gameMoves[game.id]) return; 196 - 197 - loadingMoves = { ...loadingMoves, [game.id]: true }; 198 - try { 199 - const { moves } = await fetchGameActionsFromPds( 200 - game.player_one, 201 - game.player_two, 202 - game.id 203 - ); 204 - // Convert to simple format for MiniBoard 205 - const simpleMoves = moves.map((m: MoveRecord) => ({ 206 - x: m.x, 207 - y: m.y, 208 - color: m.color as 'black' | 'white' 209 - })); 210 - gameMoves = { ...gameMoves, [game.id]: simpleMoves }; 211 - } catch (err) { 212 - console.error('Failed to fetch moves for game:', game.id, err); 213 - } finally { 214 - loadingMoves = { ...loadingMoves, [game.id]: false }; 215 - } 216 - } 217 180 </script> 218 181 219 182 <svelte:head> ··· 262 225 <div class="games-list"> 263 226 {#each currentGames as game} 264 227 <div class="game-item"> 265 - {#if gameMoves[game.id]} 266 - <div class="mini-board-wrapper"> 267 - <MiniBoard boardSize={game.board_size} moves={gameMoves[game.id]} /> 268 - </div> 269 - {:else} 270 - <div class="mini-board-placeholder"> 271 - <span class="mini-board-loading">...</span> 272 - </div> 273 - {/if} 228 + <img 229 + src="/api/games/{game.rkey}/board?size=70" 230 + alt="Board preview" 231 + class="mini-board-img" 232 + loading="lazy" 233 + /> 274 234 <div class="game-info"> 275 235 <div class="game-title">{game.title}</div> 276 236 <div> ··· 428 388 {@const myTurn = isMyTurn(game)} 429 389 {@const playing = isMyGame(game)} 430 390 <div class="game-item" class:my-turn={myTurn}> 431 - {#if gameMoves[game.id]} 432 - <div class="mini-board-wrapper"> 433 - <MiniBoard boardSize={game.board_size} moves={gameMoves[game.id]} /> 434 - </div> 435 - {:else} 436 - <div class="mini-board-placeholder"> 437 - <span class="mini-board-loading">...</span> 438 - </div> 439 - {/if} 391 + <img 392 + src="/api/games/{game.rkey}/board?size=70" 393 + alt="Board preview" 394 + class="mini-board-img" 395 + loading="lazy" 396 + /> 440 397 <div class="game-info"> 441 398 <div class="game-title-row"> 442 399 <span class="game-title">{game.title}</span> ··· 829 786 background: linear-gradient(135deg, var(--sky-apricot-light) 0%, var(--sky-white) 100%); 830 787 } 831 788 832 - .mini-board-wrapper { 789 + .mini-board-img { 833 790 flex-shrink: 0; 834 791 width: 70px; 835 792 height: 70px; 836 793 border-radius: 6px; 837 - overflow: hidden; 838 794 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); 839 795 border: 1px solid var(--sky-blue-pale); 840 - } 841 - 842 - .mini-board-placeholder { 843 - flex-shrink: 0; 844 - width: 70px; 845 - height: 70px; 846 - border-radius: 6px; 847 - background: linear-gradient(135deg, #DEB887 0%, #C4A067 100%); 848 - display: flex; 849 - align-items: center; 850 - justify-content: center; 851 - border: 1px solid var(--sky-blue-pale); 852 - } 853 - 854 - .mini-board-loading { 855 - color: rgba(92, 72, 48, 0.5); 856 - font-size: 0.75rem; 857 - animation: pulse 1.5s ease-in-out infinite; 858 - } 859 - 860 - @keyframes pulse { 861 - 0%, 100% { opacity: 0.5; } 862 - 50% { opacity: 1; } 863 796 } 864 797 865 798 .game-info {
+67
src/routes/api/games/[id]/board/+server.ts
··· 1 + import { error } from '@sveltejs/kit'; 2 + import type { RequestHandler } from './$types'; 3 + import { getDb } from '$lib/server/db'; 4 + import { fetchGameActionsFromPds } from '$lib/atproto-client'; 5 + import { generateBoardSvg, buildBoardStateFromMoves } from '$lib/server/board-svg'; 6 + 7 + /** 8 + * GET: Return an SVG representation of the current board state. 9 + * Supports query params: 10 + * - size: SVG size in pixels (default 100) 11 + */ 12 + export const GET: RequestHandler = async ({ params, url }) => { 13 + const { id: rkey } = params; 14 + const size = Math.min(500, Math.max(50, parseInt(url.searchParams.get('size') || '100', 10))); 15 + 16 + const db = getDb(); 17 + const game = await db 18 + .selectFrom('games') 19 + .selectAll() 20 + .where('rkey', '=', rkey) 21 + .executeTakeFirst(); 22 + 23 + if (!game) { 24 + throw error(404, 'Game not found'); 25 + } 26 + 27 + // Fetch moves 28 + let boardState: Array<Array<'black' | 'white' | null>> = Array.from( 29 + { length: game.board_size }, 30 + () => Array.from({ length: game.board_size }, () => null) 31 + ); 32 + let lastMove: { x: number; y: number } | null = null; 33 + 34 + try { 35 + const { moves } = await fetchGameActionsFromPds( 36 + game.player_one, 37 + game.player_two, 38 + game.id 39 + ); 40 + 41 + if (moves.length > 0) { 42 + const result = await buildBoardStateFromMoves( 43 + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), 44 + game.board_size 45 + ); 46 + boardState = result.boardState; 47 + lastMove = result.lastMove; 48 + } 49 + } catch (err) { 50 + console.error('Failed to fetch moves for board SVG:', err); 51 + // Continue with empty board 52 + } 53 + 54 + const svg = generateBoardSvg(game.board_size, boardState, lastMove, { size }); 55 + 56 + // Cache based on game status 57 + // Active games: short cache (30 seconds) 58 + // Completed games: longer cache (1 hour) 59 + const maxAge = game.status === 'completed' ? 3600 : 30; 60 + 61 + return new Response(svg, { 62 + headers: { 63 + 'Content-Type': 'image/svg+xml', 64 + 'Cache-Control': `public, max-age=${maxAge}`, 65 + }, 66 + }); 67 + };
+6 -29
src/routes/og-image/[id]/+server.ts
··· 3 3 import { getDb } from '$lib/server/db'; 4 4 import { gameTitle } from '$lib/game-titles'; 5 5 import { resolveDidToHandle, fetchGameActionsFromPds } from '$lib/atproto-client'; 6 - import tenuki from 'tenuki'; 6 + import { buildBoardStateFromMoves } from '$lib/server/board-svg'; 7 7 import { Resvg } from '@resvg/resvg-js'; 8 8 9 9 export const GET: RequestHandler = async ({ params }) => { ··· 48 48 console.log(`OG image: Found ${moves.length} moves for game ${gameRkey}`); 49 49 50 50 if (moves.length > 0) { 51 - // Create a tenuki game and replay moves 52 - const tenukiGame = new tenuki.Game({ boardSize }); 53 - 54 - for (const move of moves) { 55 - try { 56 - // tenuki uses (y, x) format where y is row and x is column 57 - const result = tenukiGame.playAt(move.y, move.x); 58 - console.log(`OG image: Playing move at (${move.x}, ${move.y}) color=${move.color}, result=${result}`); 59 - lastMove = { x: move.x, y: move.y }; 60 - } catch (moveErr) { 61 - console.error(`OG image: Failed to play move at (${move.x}, ${move.y}):`, moveErr); 62 - } 63 - } 64 - 65 - // Extract board state from tenuki using intersectionAt 66 - // Initialize empty 2D board 67 - boardState = Array.from({ length: boardSize }, () => 68 - Array.from({ length: boardSize }, () => null as 'black' | 'white' | null) 51 + const result = await buildBoardStateFromMoves( 52 + moves.map(m => ({ x: m.x, y: m.y, color: m.color })), 53 + boardSize 69 54 ); 70 - 71 - // Read each intersection 72 - for (let y = 0; y < boardSize; y++) { 73 - for (let x = 0; x < boardSize; x++) { 74 - const intersection = tenukiGame.intersectionAt(y, x); 75 - if (intersection.value === 'black' || intersection.value === 'white') { 76 - boardState[y][x] = intersection.value; 77 - } 78 - } 79 - } 55 + boardState = result.boardState; 56 + lastMove = result.lastMove; 80 57 81 58 console.log(`OG image: Extracted board state, stones:`, boardState.flat().filter(s => s !== null).length); 82 59 }