Architecture
System Context
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.
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) plusai_difficulty/ai_enginefor whichever side is AI-controlled.moves— one row per half-move (move_number,side,notation, the resultingfen), indexed by(game_id, move_number). Move legality is not validated on write —RecordMovetrusts the caller’snotation/fenand 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, anamelabel, andsource_game_idlinking back to achess_gamesrow 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:" + gameIDinstead, 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 thehistorical_gamesrow, move number, side, and notation played. This is what makesPositionMoves(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:
- Persist the move as given (no legality check).
- 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 thegoblin-sharkclient’s board renderer. - 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). - If the game ended, persist the new
GameStatuson thechess_gamesrow and hand the game off to thearchive.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:
- Stores it as a
historical_gamesrow (namefrom the request label, falling back to the PGN’sEventtag, falling back to"Untitled Game"). - Replays the game ply by ply (
pgn.ReplayMoves) and bulk-inserts oneposition_indexrow per ply, keyed byFENHash.
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):
| Service | RPCs |
|---|---|
ChessGameService | ListChessGames, GetChessGame, CreateChessGame, UpdateChessGame |
MoveService | ListMoves, RecordMove |
ChessEngineService | GetNextMove |
ChessDatabaseService | ImportPGN, 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
| Process | Source | Port | Notes |
|---|---|---|---|
| API server | cmd/api/main.go | 9000 | The only process; ships in Dockerfile (multi-stage, scratch-based) |
External Dependencies (key)
| Package | Role |
|---|---|
connectrpc.com/connect | Connect-RPC server |
connectrpc.com/cors | CORS headers for Connect |
github.com/Masterminds/squirrel | SQL query builder |
github.com/pressly/goose/v3 | Database migrations |
github.com/lib/pq | PostgreSQL driver |
github.com/google/uuid | UUID generation |
google.golang.org/protobuf | Generated protobuf types |
go.uber.org/zap | Structured logging |