Architecture

System Context

C4Context title System Context — Rook Person(player, "Player", "Plays chess as white and/or black; browses the historical game archive") System_Ext(ui, "goblin-shark", "Board renderer / client UI — consumes Rook's Connect-RPC API") System(rook, "Rook", "Chess game engine and service — games, moves, AI opponent, PGN archive") SystemDb(postgres, "PostgreSQL", "Games, moves, historical games, indexed positions") Rel(player, ui, "Plays via") Rel(ui, rook, "ConnectRPC") Rel(rook, postgres, "Reads / writes")

Overview

Rook is a single-binary Go service (cmd/api) that exposes a Connect-RPC API over HTTP/2 (h2c) on port 9000. There is no separate worker process, no message queue, and no cache — every request is handled synchronously against PostgreSQL. The service is organized as four domain packages under lib/chess/, each following the same shape: an interface + request/response types, a *xxxService implementing it, and a thin ConnectRPC handler in grpc/ that adapts wire types to the service interface. All four domains share one PostgreSQL-backed store (lib/repo/sql/local), which implements every repository interface the domains need.

C4Container title Rook — Internal Containers Boundary(rook, "Rook") { Container(api, "cmd/api", "Go / ConnectRPC h2c :9000", "Registers all four service handlers + /health, /info") Container(gameSvc, "game.Service", "Go", "ChessGameService — List/Get/Create/UpdateChessGame") Container(moveSvc, "move.Service", "Go", "MoveService — ListMoves, RecordMove (+ game-over detection)") Container(engineSvc, "engine.Service", "Go", "ChessEngineService — GetNextMove (AI opponent)") Container(dbSvc, "database.Service", "Go", "ChessDatabaseService — PGN import, position/opening lookup, archive search") Container(archiver, "archive.Archiver", "Go", "Converts a finished live game to a PGN-backed HistoricalGame") Container(rules, "rules", "Go", "Dependency-free legal-move / check / checkmate / stalemate / draw evaluator") Container(pgn, "pgn", "Go", "PGN parsing, SAN generation, FEN hashing, ECO lookup") Container(sunfish, "engine/sunfish", "Go", "Ported Sunfish search engine — computes the AI's next move") ContainerDb(store, "local.Database", "PostgreSQL / squirrel + goose", "chess_games · moves · historical_games · position_index") } SystemDb(postgres, "PostgreSQL", "") Rel(api, gameSvc, "CreateChessGame · UpdateChessGame · ...") Rel(api, moveSvc, "RecordMove · ListMoves") Rel(api, engineSvc, "GetNextMove") Rel(api, dbSvc, "ImportPGN · SearchGames · ...") Rel(moveSvc, rules, "Evaluate() after every move; DrawByFiftyMoveRule / DrawByThreefoldRepetition") Rel(moveSvc, archiver, "ArchiveGame() once a game ends") Rel(archiver, pgn, "BuildSAN()") Rel(engineSvc, sunfish, "GetBestMove(fen, maxNodes)") Rel(dbSvc, pgn, "ParsePGN · ReplayMoves · FENHash · LookupECO") Rel(gameSvc, store, "SQL") Rel(moveSvc, store, "SQL") Rel(dbSvc, store, "SQL") Rel(archiver, store, "SQL") Rel(store, postgres, "database/sql + squirrel")

Data Model

Four tables, created by goose migrations in lib/repo/sql/local/migrations/, run automatically against DATABASE_URL on startup:

  • chess_games — one row per live game: white_player/black_player (free-text names), status (in-progress / white-wins / black-wins / draw / abandoned), instance_uuid, and, per side, white_player_type/black_player_type (human or AI) plus ai_difficulty/ai_engine for whichever side is AI-controlled.
  • moves — one row per half-move (move_number, side, notation, the resulting fen), indexed by (game_id, move_number). Move legality is not validated on write — RecordMove trusts the caller’s notation/fen and only evaluates the resulting FEN afterward to detect game-over conditions.
  • historical_games — archived/imported games: PGN tag fields (event, site, white, black, result, eco, ELOs, …), the full PGN movetext, a name label, and source_game_id linking back to a chess_games row when the game was archived from a live game rather than imported. Deduplicated on a hash: bulk PGN import hashes the movetext; live-game archival hashes "source-game:" + gameID instead, so identical movetext from two different games (e.g. two Fool’s Mates) never collides.
  • position_index — one row per ply reached while importing a PGN, keyed by a hash of the FEN’s position-only prefix (fen_hash), pointing back to the historical_games row, move number, side, and notation played. This is what makes PositionMoves (statistics for a position) and opening lookup fast without re-parsing every archived PGN on every request.

StartingFEN (lib/chess/rules) is the only starting position any live game uses — CreateChessGameRequest has no custom-FEN field.

Move Recording & Game-Over Detection

MoveService.RecordMove (lib/chess/move/service.go) is the core of a live game:

  1. Persist the move as given (no legality check).
  2. Evaluate the resulting FEN with rules.Evaluate — this is a dependency-free legal-move generator (lib/chess/rules/rules.go) that determines whether the side to move has any legal move at all, and if not, whether they’re in check (checkmate) or not (stalemate). It implements standard piece movement but not castling or en passant, matching the scope of the move generation already used by the goblin-shark client’s board renderer.
  3. If the game isn’t already decided by checkmate/stalemate, check draw conditions (lib/chess/rules/draw.go) against the full move history: the fifty-move rule (last 100 plies with no capture and no pawn move) and threefold repetition (the current position, by piece placement + side to move, having occurred three times).
  4. If the game ended, persist the new GameStatus on the chess_games row and hand the game off to the archive.Archiver.

AI Opponent

ChessEngineService.GetNextMove (lib/chess/engine/service.go) dispatches on engine_name — currently only "sunfish" (also the default for empty). lib/engine/sunfish is a Go port of the Sunfish-style engine (via an intermediate zserge/carnatus port): given a FEN and a max_nodes search budget, it runs its own search and returns the best move in coordinate notation plus the resulting FEN. GameService.CreateChessGame defaults an AI-controlled side’s engine to "sunfish" and its difficulty (search node budget) to 1000 if unset.

Archiving

archive.Archiver.ArchiveGame (lib/chess/archive/service.go) is called by MoveService once a live game ends. It is idempotent (checks ExistsBySourceGameID first), rebuilds the game’s full move history into SAN via pgn.BuildSAN, and writes a new historical_games row tagged Event: "Casual Game" with a Name of "<white> vs <black>" and source_game_id pointing back at the live game. This is the only path that produces a HistoricalGame from a live game — ImportPGN is strictly for external PGN corpora.

PGN Import & Position Indexing

ChessDatabaseService.ImportPGN (lib/chess/database/service.go) parses a PGN blob (lib/chess/database/pgn, potentially containing multiple games), hashes each game’s movetext to dedupe against already-imported games, and for each new game:

  1. Stores it as a historical_games row (name from the request label, falling back to the PGN’s Event tag, falling back to "Untitled Game").
  2. Replays the game ply by ply (pgn.ReplayMoves) and bulk-inserts one position_index row per ply, keyed by FENHash.

PositionMoves queries position_index by FEN hash and aggregates results (games/white-wins/draws/black-wins) per notation, powering “what’s been played from this position” lookups. LookupOpening looks up ECO/opening name/variation for a FEN via an embedded ECO table (lib/chess/database/pgn/eco.tsv). SearchGames/GetHistoricalGame search and retrieve archived games; GetHistoricalGame additionally expands the stored PGN into a per-ply replay (move/side/notation/FEN) on request, so a client can scrub through the game without its own PGN parser.

At startup, cmd/api/main.go also seeds the database from every *.pgn file in PGN_SEED_DIR (if set), via the same ImportPGN path — safe to run on every restart since import dedupes by hash.

Connect-RPC API Surface

All four services are registered on one http.ServeMux, wrapped per-handler with permissive CORS (connectrpc.com/cors, wildcard origin):

ServiceRPCs
ChessGameServiceListChessGames, GetChessGame, CreateChessGame, UpdateChessGame
MoveServiceListMoves, RecordMove
ChessEngineServiceGetNextMove
ChessDatabaseServiceImportPGN, PositionMoves, LookupOpening, SearchGames, GetHistoricalGame, UpdateHistoricalGame

Plus plain HTTP /health and /info (build commit/time) endpoints, unauthenticated.

Transport

The API server uses h2c (cleartext HTTP/2) via golang.org/x/net/http2/h2c, so it’s reachable by both native gRPC clients and Connect-RPC/grpc-web clients without TLS termination in front of it.

Repository Layer

lib/repo/sql/local is the single PostgreSQL-backed store implementing every domain’s repository interface (game.GameRepository, move.MoveRepository, archive.*Repository, database.DatabaseRepository). SQL is built with Masterminds/squirrel. Schema migrations live in lib/repo/sql/local/migrations/*.up.sql (goose format) and are also copied into the Docker image (/migrations) for out-of-process migration runs.

Protobuf / Connect-RPC

Schemas live in schemas/rook/v1/ (game.proto, move.proto, historical_game.proto, pgn.proto, and services/*.proto). Generated Go code is committed under lib/schemas/rook/. Regenerate with:

buf generate

Process Inventory

ProcessSourcePortNotes
API servercmd/api/main.go9000The only process; ships in Dockerfile (multi-stage, scratch-based)

External Dependencies (key)

PackageRole
connectrpc.com/connectConnect-RPC server
connectrpc.com/corsCORS headers for Connect
github.com/Masterminds/squirrelSQL query builder
github.com/pressly/goose/v3Database migrations
github.com/lib/pqPostgreSQL driver
github.com/google/uuidUUID generation
google.golang.org/protobufGenerated protobuf types
go.uber.org/zapStructured logging