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.

Generate OG image with actual game board state

- Install tenuki library for Go board logic
- Fetch moves from PDS and replay them using tenuki
- Render actual stone positions on the SVG board
- Add radial gradients for realistic stone appearance
- Show last move indicator (circle marker)
- Support star points (hoshi) for all board sizes

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

+150 -38
+15 -1
package-lock.json
··· 22 22 "better-sqlite3": "^11.0.0", 23 23 "dotenv": "^17.2.3", 24 24 "jgoboard": "github:jokkebk/jgoboard", 25 - "kysely": "^0.27.0" 25 + "kysely": "^0.27.0", 26 + "tenuki": "^0.3.1" 26 27 }, 27 28 "devDependencies": { 28 29 "@sveltejs/adapter-auto": "^3.0.0", ··· 3951 3952 "node": ">= 6" 3952 3953 } 3953 3954 }, 3955 + "node_modules/tenuki": { 3956 + "version": "0.3.1", 3957 + "resolved": "https://registry.npmjs.org/tenuki/-/tenuki-0.3.1.tgz", 3958 + "integrity": "sha512-4obVv+CHn0QXrtHEZOYXE69xweUAae/iHfEz6oqM+dKTcdL8b0G44knfjLtepu4UHdwW0OzxurXDXoZKzKOIIQ==", 3959 + "engines": { 3960 + "node": ">=6.0.0" 3961 + } 3962 + }, 3954 3963 "node_modules/thread-stream": { 3955 3964 "version": "2.7.0", 3956 3965 "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-2.7.0.tgz", ··· 6849 6858 } 6850 6859 } 6851 6860 } 6861 + }, 6862 + "tenuki": { 6863 + "version": "0.3.1", 6864 + "resolved": "https://registry.npmjs.org/tenuki/-/tenuki-0.3.1.tgz", 6865 + "integrity": "sha512-4obVv+CHn0QXrtHEZOYXE69xweUAae/iHfEz6oqM+dKTcdL8b0G44knfjLtepu4UHdwW0OzxurXDXoZKzKOIIQ==" 6852 6866 }, 6853 6867 "thread-stream": { 6854 6868 "version": "2.7.0",
+2 -1
package.json
··· 36 36 "better-sqlite3": "^11.0.0", 37 37 "dotenv": "^17.2.3", 38 38 "jgoboard": "github:jokkebk/jgoboard", 39 - "kysely": "^0.27.0" 39 + "kysely": "^0.27.0", 40 + "tenuki": "^0.3.1" 40 41 } 41 42 }
+133 -36
src/routes/og-image/[id]/+server.ts
··· 2 2 import type { RequestHandler } from './$types'; 3 3 import { getDb } from '$lib/server/db'; 4 4 import { gameTitle } from '$lib/game-titles'; 5 - import { resolveDidToHandle } from '$lib/atproto-client'; 5 + import { resolveDidToHandle, fetchGameActionsFromPds } from '$lib/atproto-client'; 6 + import tenuki from 'tenuki'; 6 7 7 8 export const GET: RequestHandler = async ({ params }) => { 8 9 const gameRkey = params.id; ··· 31 32 // Keep fallback values 32 33 } 33 34 35 + // Fetch moves and build board state 36 + const boardSize = game.board_size; 37 + let boardState: Array<Array<'black' | 'white' | null>> = []; 38 + let lastMove: { x: number; y: number } | null = null; 39 + 40 + try { 41 + const { moves } = await fetchGameActionsFromPds( 42 + game.player_one, 43 + game.player_two, 44 + game.id // AT URI 45 + ); 46 + 47 + if (moves.length > 0) { 48 + // Create a tenuki game and replay moves 49 + const tenukiGame = new tenuki.Game({ boardSize }); 50 + 51 + for (const move of moves) { 52 + try { 53 + tenukiGame.playAt(move.y, move.x); // tenuki uses (y, x) format 54 + lastMove = { x: move.x, y: move.y }; 55 + } catch { 56 + // Skip invalid moves 57 + } 58 + } 59 + 60 + // Extract board state from tenuki 61 + boardState = tenukiGame.currentState().intersections.map((row: any[]) => 62 + row.map((intersection: any) => { 63 + if (intersection.isBlack()) return 'black'; 64 + if (intersection.isWhite()) return 'white'; 65 + return null; 66 + }) 67 + ); 68 + } 69 + } catch (err) { 70 + console.error('Failed to fetch moves for OG image:', err); 71 + // Continue with empty board 72 + } 73 + 34 74 const title = gameTitle(gameRkey); 35 - const boardSize = game.board_size; 36 75 const status = game.status; 37 76 38 77 // Status text and colors ··· 49 88 statusColor = '#5A7A90'; 50 89 } 51 90 91 + // Generate board SVG with actual stones 92 + const boardSvgSize = 350; 93 + const boardSvg = generateBoardSvg(boardSize, boardSvgSize, boardState, lastMove); 94 + 52 95 // Generate SVG 53 96 const svg = ` 54 97 <svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg"> ··· 66 109 <stop offset="0%" style="stop-color:#DEB887;stop-opacity:1" /> 67 110 <stop offset="100%" style="stop-color:#C4A067;stop-opacity:1" /> 68 111 </linearGradient> 112 + <radialGradient id="blackStone" cx="35%" cy="35%"> 113 + <stop offset="0%" style="stop-color:#4a4a4a"/> 114 + <stop offset="100%" style="stop-color:#1a1a1a"/> 115 + </radialGradient> 116 + <radialGradient id="whiteStone" cx="35%" cy="35%"> 117 + <stop offset="0%" style="stop-color:#ffffff"/> 118 + <stop offset="100%" style="stop-color:#d0d0d0"/> 119 + </radialGradient> 69 120 </defs> 70 121 71 122 <!-- Background --> ··· 81 132 <rect x="50" y="50" width="1100" height="530" rx="30" ry="30" fill="rgba(255,255,255,0.95)" 82 133 stroke="rgba(212,229,239,0.5)" stroke-width="2"/> 83 134 84 - <!-- Mini board illustration --> 85 - <g transform="translate(750, 150)"> 86 - <rect width="350" height="350" rx="15" fill="url(#boardGradient)" stroke="#8B7355" stroke-width="3"/> 87 - <!-- Grid lines --> 88 - ${generateGridLines(350, 9)} 89 - <!-- Sample stones --> 90 - <circle cx="87.5" cy="87.5" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 91 - <circle cx="175" cy="175" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 92 - <circle cx="262.5" cy="87.5" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 93 - <circle cx="87.5" cy="262.5" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 94 - <circle cx="175" cy="87.5" r="16" fill="#f5f5f5" stroke="#ccc" stroke-width="1"/> 95 - <circle cx="262.5" cy="175" r="16" fill="#1a1a1a" stroke="#333" stroke-width="1"/> 135 + <!-- Board with actual game state --> 136 + <g transform="translate(720, 130)"> 137 + ${boardSvg} 96 138 </g> 97 139 98 140 <!-- Title --> ··· 118 160 119 161 <!-- Players --> 120 162 <g transform="translate(100, 400)"> 121 - <circle cx="15" cy="0" r="15" fill="#1a1a1a"/> 163 + <circle cx="15" cy="0" r="15" fill="url(#blackStone)"/> 122 164 <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#455D6E"> 123 165 ${escapeXml(playerOneHandle)} 124 166 </text> ··· 126 168 127 169 ${playerTwoHandle ? ` 128 170 <g transform="translate(100, 460)"> 129 - <circle cx="15" cy="0" r="15" fill="#f5f5f5" stroke="#ccc" stroke-width="2"/> 171 + <circle cx="15" cy="0" r="15" fill="url(#whiteStone)" stroke="#ccc" stroke-width="1"/> 130 172 <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#455D6E"> 131 173 ${escapeXml(playerTwoHandle)} 132 174 </text> 133 175 </g> 134 176 ` : ` 135 177 <g transform="translate(100, 460)"> 136 - <circle cx="15" cy="0" r="15" fill="#f5f5f5" stroke="#ccc" stroke-width="2"/> 178 + <circle cx="15" cy="0" r="15" fill="url(#whiteStone)" stroke="#ccc" stroke-width="1"/> 137 179 <text x="45" y="7" font-family="Inter, system-ui, sans-serif" font-size="22" fill="#9CA3AF" font-style="italic"> 138 180 Waiting for opponent... 139 181 </text> ··· 145 187 return new Response(svg, { 146 188 headers: { 147 189 'Content-Type': 'image/svg+xml', 148 - 'Cache-Control': 'public, max-age=3600', 190 + 'Cache-Control': 'public, max-age=300', // 5 min cache for active games 149 191 }, 150 192 }); 151 193 }; 152 194 153 - function generateGridLines(size: number, lines: number): string { 154 - const step = size / (lines - 1); 155 - let result = ''; 195 + function generateBoardSvg( 196 + boardSize: number, 197 + svgSize: number, 198 + boardState: Array<Array<'black' | 'white' | null>>, 199 + lastMove: { x: number; y: number } | null 200 + ): string { 201 + const padding = 15; 202 + const innerSize = svgSize - padding * 2; 203 + const step = innerSize / (boardSize - 1); 204 + const stoneRadius = step * 0.45; 205 + 206 + let svg = ''; 207 + 208 + // Board background 209 + svg += `<rect width="${svgSize}" height="${svgSize}" rx="10" fill="url(#boardGradient)" stroke="#8B7355" stroke-width="2"/>`; 156 210 157 - for (let i = 0; i < lines; i++) { 158 - const pos = i * step; 211 + // Grid lines 212 + for (let i = 0; i < boardSize; i++) { 213 + const pos = padding + i * step; 159 214 // Horizontal lines 160 - result += `<line x1="0" y1="${pos}" x2="${size}" y2="${pos}" stroke="#5C4830" stroke-width="1" opacity="0.6"/>`; 215 + svg += `<line x1="${padding}" y1="${pos}" x2="${svgSize - padding}" y2="${pos}" stroke="#5C4830" stroke-width="1" opacity="0.7"/>`; 161 216 // Vertical lines 162 - result += `<line x1="${pos}" y1="0" x2="${pos}" y2="${size}" stroke="#5C4830" stroke-width="1" opacity="0.6"/>`; 217 + svg += `<line x1="${pos}" y1="${padding}" x2="${pos}" y2="${svgSize - padding}" stroke="#5C4830" stroke-width="1" opacity="0.7"/>`; 163 218 } 164 219 165 - // Star points for 9x9 166 - const starPoints = [ 167 - [step * 2, step * 2], 168 - [step * 6, step * 2], 169 - [step * 2, step * 6], 170 - [step * 6, step * 6], 171 - [step * 4, step * 4], // center 172 - ]; 173 - 220 + // Star points (hoshi) 221 + const starPoints = getStarPoints(boardSize); 174 222 for (const [x, y] of starPoints) { 175 - result += `<circle cx="${x}" cy="${y}" r="4" fill="#5C4830" opacity="0.8"/>`; 223 + const px = padding + x * step; 224 + const py = padding + y * step; 225 + svg += `<circle cx="${px}" cy="${py}" r="3" fill="#5C4830" opacity="0.8"/>`; 176 226 } 177 227 178 - return result; 228 + // Stones 229 + for (let y = 0; y < boardState.length; y++) { 230 + for (let x = 0; x < (boardState[y]?.length || 0); x++) { 231 + const stone = boardState[y][x]; 232 + if (stone) { 233 + const px = padding + x * step; 234 + const py = padding + y * step; 235 + const isLastMove = lastMove && lastMove.x === x && lastMove.y === y; 236 + 237 + if (stone === 'black') { 238 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius}" fill="url(#blackStone)"/>`; 239 + if (isLastMove) { 240 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius * 0.35}" fill="none" stroke="#fff" stroke-width="2"/>`; 241 + } 242 + } else { 243 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius}" fill="url(#whiteStone)" stroke="#aaa" stroke-width="1"/>`; 244 + if (isLastMove) { 245 + svg += `<circle cx="${px}" cy="${py}" r="${stoneRadius * 0.35}" fill="none" stroke="#333" stroke-width="2"/>`; 246 + } 247 + } 248 + } 249 + } 250 + } 251 + 252 + return svg; 253 + } 254 + 255 + function getStarPoints(boardSize: number): Array<[number, number]> { 256 + if (boardSize === 9) { 257 + return [ 258 + [2, 2], [6, 2], 259 + [4, 4], 260 + [2, 6], [6, 6], 261 + ]; 262 + } else if (boardSize === 13) { 263 + return [ 264 + [3, 3], [9, 3], 265 + [6, 6], 266 + [3, 9], [9, 9], 267 + ]; 268 + } else if (boardSize === 19) { 269 + return [ 270 + [3, 3], [9, 3], [15, 3], 271 + [3, 9], [9, 9], [15, 9], 272 + [3, 15], [9, 15], [15, 15], 273 + ]; 274 + } 275 + return []; 179 276 } 180 277 181 278 function escapeXml(str: string): string {