feat: rebuild YatzCLI v2 from scratch - #11
Conversation
Single binary architecture with three play modes: - Local AI play via MCP server for Claude Code integration - Interactive CLI with bubbletea TUI - P2P online play with serverless matchmaking (Lambda + DynamoDB)
- Clarify Hold+Roll semantics and Roll-only-for-initial-roll - Add RNG injection for deterministic testing - Define zero scoring behavior for unmet categories - Add Yahtzee Bonus to Non-Goals explicitly - Define Category as string type in GameClient interface - Cap local AI opponents at 1-3 - Document host's own turn flow and state_update broadcasting - Add disconnection and invalid action handling policies - Add endpoint detection via WebSocket sourceIp - Correct API Gateway WebSocket pricing model - Add testing strategy section - Add reconnection/state recovery to Non-Goals
16 tasks across 6 phases: project setup, engine, GameClient+CLI, MCP server, P2P online play, matchmaking, and distribution.
- Fix advanceTurn() to reset Phase to PhaseRolling - Fix TestGame_Hold_MaxRolls to need 3 rolls before PhaseChoosing - Fix NewActionMsg to accept ActionPayload directly - Fix FindMatch signature to use int for port
Remove all v1 packages (cmd/, client/, server/, game/, messages/, network/). Initialize fresh Go 1.22 module with cobra for subcommand management.
AIPlayer picks the available category with the highest score for the current dice on each turn. PlayTurn rolls once and scores immediately.
LocalClient wraps Game for local play and auto-plays AI opponents after the human scores via runAITurns.
Implement a bubbletea v2-based terminal UI for local Yahtzee games. The play command supports configurable AI opponents and player name.
Implement 6 MCP tools (new_game, roll_dice, hold_dice, score, get_state, get_scorecard) using mark3labs/mcp-go. Add mcp subcommand to the CLI. All responses are human-readable text with formatted dice display, scorecards, and game state.
Resolve merge conflicts in cmd/yatz/main.go and go.mod to combine both play and mcp subcommands.
Define length-prefixed JSON-over-TCP protocol with message types for handshake, game_start, turn_start, action, state_update, game_over, and error. Include constructors, decoders, and round-trip tests.
Implement Host that listens on TCP, accepts a guest, runs handshake, and manages the authoritative game loop. HostGameClient wraps LocalClient to broadcast state updates to the guest after each action.
Implement RemoteClient that connects to host via TCP, sends actions, and receives state updates through a background listener goroutine. Wire up yatz host and yatz join cobra commands.
Add match package with WebSocket-based matchmaking client that connects to a matchmaking server, sends player info, and waits for a match result. Add `yatz match` subcommand that auto-selects a free port, finds an opponent via the matchmaking server, then starts as host or guest.
Implement API Gateway WebSocket + Lambda + DynamoDB matchmaking: - $connect: accept connection (no-op) - $default: register as waiting player or match with existing one - $disconnect: remove from waiting table Both matched players are notified via PostToConnection with opponent endpoint, name, and host/guest role.
Add end-to-end tests that play complete 13-round Yahtzee games: - MCP: full game through tool calls (roll/score and roll/hold/score) - P2P: host-guest protocol exchange with turn alternation Also: add CLAUDE.md, update README for v2, fix CI Go version to 1.24, remove accidentally tracked yatz binary from index.
E2E tests (TestE2E_*) now skip under `go test -short`, allowing fast unit-only runs during development. CI splits into two jobs: unit tests run first with -short, then E2E tests run separately after passing.
There was a problem hiding this comment.
Pull request overview
Rebuilds YatzCLI as a single yatz binary with a new engine/ core, Bubble Tea TUI, MCP server integration, and a new P2P + serverless matchmaking networking stack, replacing the legacy gob-based client/server implementation.
Changes:
- Replaced legacy
client/,server/,game/,messages/,network/with a new pureengine/(state machine + scoring + AI + GameClient). - Added new interactive TUI (
cli/), MCP server (mcp/), P2P protocol + host/guest (p2p/), matchmaking client (match/) and AWS Lambda handler (lambda/). - Updated module path, docs, and CI/release configuration for the new architecture and distribution.
Reviewed changes
Copilot reviewed 62 out of 66 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| server/server.go | Removes legacy TCP server implementation |
| server/room.go | Removes legacy room model |
| server/room_manager.go | Removes legacy room manager |
| server/room_controller.go | Removes legacy room controller |
| server/room_controller_test.go | Removes legacy room controller tests |
| server/player.go | Removes legacy Player model |
| server/handler.go | Removes legacy connection handler |
| server/gameplay_controller.go | Removes legacy gameplay controller |
| server/gameplay_controller_test.go | Removes legacy gameplay controller tests |
| server/controller.go | Removes legacy controller interface |
| server/controller_test.go | Removes legacy commented-out tests |
| cmd/server/main.go | Removes legacy server entrypoint |
| cmd/client/main.go | Removes legacy client entrypoint |
| client/client.go | Removes legacy gob-based client |
| client/client_test.go | Removes legacy client tests |
| client/console_io_handler.go | Removes legacy survey-based UI |
| client/iohandler.go | Removes legacy IO abstraction |
| client/mock_iohandler.go | Removes legacy IO mocks |
| network/connections.go | Removes legacy gob connection abstraction |
| network/mock_connection.go | Removes legacy mock connection |
| messages/messages.go | Removes legacy message schema |
| game/game.go | Removes legacy shared game types |
| game/score.go | Removes legacy scoring implementation |
| game/yahtzee.go | Removes legacy gameplay constants/types |
| engine/category.go | Adds new category definitions/constants |
| engine/dice.go | Adds deterministic dice rolling/rerolling helpers |
| engine/dice_test.go | Adds dice unit tests |
| engine/scoring.go | Adds scoring for all categories |
| engine/scoring_test.go | Adds scoring unit tests |
| engine/scorecard.go | Adds new scorecard representation and helpers |
| engine/scorecard_test.go | Adds scorecard unit tests |
| engine/game.go | Adds new game state machine + turn progression |
| engine/game_test.go | Adds game state machine unit tests |
| engine/ai.go | Adds greedy AI player |
| engine/ai_test.go | Adds AI unit tests |
| engine/client.go | Adds GameClient + LocalClient implementation |
| engine/client_test.go | Adds LocalClient tests |
| cli/ui.go | Adds Bubble Tea entrypoint for the TUI |
| cli/model.go | Implements rolling/choosing/game-over TUI flow |
| p2p/protocol.go | Adds length-prefixed JSON protocol + codecs |
| p2p/protocol_test.go | Adds protocol round-trip tests |
| p2p/host.go | Adds host-authority P2P game runner |
| p2p/host_test.go | Adds P2P host tests |
| p2p/guest.go | Adds RemoteClient guest implementation |
| p2p/guest_test.go | Adds guest/RemoteClient tests |
| p2p/e2e_test.go | Adds P2P full-game E2E tests (skipped in -short) |
| mcp/server.go | Adds MCP server exposing game tools |
| mcp/server_test.go | Adds MCP tool handler tests |
| mcp/e2e_test.go | Adds MCP full-game E2E tests (skipped in -short) |
| match/client.go | Adds WebSocket matchmaking client |
| match/client_test.go | Adds matchmaking client tests |
| lambda/handler.go | Adds AWS Lambda matchmaking handler (DynamoDB + API GW) |
| lambda/handler_test.go | Adds Lambda handler unit tests with mocks |
| README.md | Updates installation/usage/architecture docs for v2 |
| docs/superpowers/specs/2026-03-22-yatzcli-rebuild-design.md | Adds design spec for the rebuild |
| cmd/yatz/main.go | Adds cobra-based single-binary CLI entrypoint |
| CLAUDE.md | Adds repo usage + architecture notes for Claude Code |
| go.mod | Updates module path, Go version, and dependencies |
| go.sum | Updates dependency lockfile |
| .github/workflows/ci.yml | Adds unit + E2E GitHub Actions workflows |
| .github/workflows/release.yml | Adds tag-based GoReleaser workflow |
| .goreleaser.yml | Adds GoReleaser build/archive config |
| .gitignore | Ignores new built binary name (yatz) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| type Scorecard struct { | ||
| scores map[Category]*int | ||
| } |
There was a problem hiding this comment.
Scorecard has only an unexported field (scores), so when engine.GameState is JSON-marshaled (e.g., in P2P state_update / game_start messages) the scorecard will serialize as {} and the guest will never see filled categories or totals. Consider making the field exported (and JSON-tagged) or implementing MarshalJSON/UnmarshalJSON so scorecards round-trip correctly over the wire.
| switch msg.Type { | ||
| case MsgStateUpdate: | ||
| sp, err := DecodeState(msg) | ||
| if err != nil { | ||
| rc.responseCh <- responseResult{err: fmt.Errorf("decode state_update: %w", err)} | ||
| continue | ||
| } | ||
| rc.setLastState(&sp.State) | ||
| rc.responseCh <- responseResult{state: &sp.State} | ||
|
|
There was a problem hiding this comment.
listen() writes to responseCh with a blocking send for every state_update. When the guest is waiting for turn_start (e.g., inside Score() after ending their turn), there may be no goroutine receiving from responseCh, so the listener can deadlock and stop processing further messages (including turn_start / game_over). Consider making these sends non-blocking (drop/overwrite when no waiter), or gating delivery to responseCh only when an action request is in-flight, while still updating lastState for UI.
| // ReadMessage reads a length-prefixed JSON message from r. | ||
| func ReadMessage(r io.Reader) (*Message, error) { | ||
| var length uint32 | ||
| if err := binary.Read(r, binary.BigEndian, &length); err != nil { | ||
| return nil, fmt.Errorf("read length: %w", err) | ||
| } | ||
| data := make([]byte, length) | ||
| if _, err := io.ReadFull(r, data); err != nil { | ||
| return nil, fmt.Errorf("read payload: %w", err) | ||
| } |
There was a problem hiding this comment.
ReadMessage trusts the peer-provided length and allocates make([]byte, length) with no upper bound. A malicious/buggy peer can send a huge length prefix and trigger excessive memory allocation/DoS. Introduce a reasonable max message size (e.g., 1MB) and reject frames exceeding it before allocating/reading.
| func (g *Game) Score(category Category) error { | ||
| if g.Phase == PhaseFinished { | ||
| return errors.New("cannot score: game is finished") | ||
| } | ||
| if g.RollCount == 0 { | ||
| return errors.New("cannot score: must roll first") | ||
| } | ||
| player := &g.Players[g.Current] | ||
| if player.Scorecard.IsFilled(category) { | ||
| return fmt.Errorf("cannot score: category %s already filled", category) | ||
| } | ||
| score := CalcScore(category, g.Dice) | ||
| player.Scorecard.Fill(category, score) | ||
| g.advanceTurn() | ||
| return nil |
There was a problem hiding this comment.
Score() accepts any Category string and will happily fill unknown categories (since CalcScore returns 0 for unrecognized values). This allows clients (MCP/P2P) to send arbitrary category names, bypass normal scorecard filling, and potentially invalidate game results. Consider validating category is one of AllCategories (and ideally currently available for the player) before scoring.
| holdDiceTool := mcp.NewTool("hold_dice", | ||
| mcp.WithDescription("Hold specified dice and reroll the others. Indices are 0-4."), | ||
| mcp.WithString("indices", mcp.Required(), mcp.Description("JSON array of dice indices to hold, e.g. [0,2,4]")), | ||
| ) | ||
| s.AddTool(holdDiceTool, gs.handleHoldDice) |
There was a problem hiding this comment.
The hold_dice tool declares indices as a string parameter, but handleHoldDice reads it via GetIntSlice. This schema/handler mismatch can break real MCP clients that validate arguments against the tool schema. Define indices as an array of numbers/ints in the tool schema (instead of a stringified JSON array), or update the handler to parse a string.
| - Plays as a participant and serves game state to guest | ||
| - Host uses `LocalClient`, guest uses `RemoteClient` | ||
|
|
||
| **Host's own turn:** Host operates the engine directly via `LocalClient`. After each action (Roll/Hold/Score), host sends `state_update` to guest. Guest receives `turn_start` at the beginning of every turn (both host's and guest's) so it can display the current game state. |
There was a problem hiding this comment.
This spec says the guest receives turn_start at the beginning of every turn, but the current host implementation only sends turn_start when the guest’s turn begins (see Host.handleGuestTurn()). Either update the spec to match the implementation, or adjust the host to emit turn_start for host turns too.
| func (h *Handler) handleMessage(ctx context.Context, event events.APIGatewayWebsocketProxyRequest) error { | ||
| var msg ClientMessage | ||
| if err := json.Unmarshal([]byte(event.Body), &msg); err != nil { | ||
| return fmt.Errorf("parsing client message: %w", err) | ||
| } | ||
|
|
||
| connectionID := event.RequestContext.ConnectionID | ||
| sourceIP := event.RequestContext.Identity.SourceIP | ||
| endpoint := sourceIP + ":" + strconv.Itoa(msg.Port) | ||
|
|
There was a problem hiding this comment.
handleMessage trusts msg.Port from the client and blindly constructs endpoint := sourceIP + ":" + strconv.Itoa(msg.Port). This can produce invalid endpoints (0/negative/out-of-range ports) and could be abused to poison matchmaking results. Consider validating Port is within 1-65535 (and optionally rejecting empty sourceIP) before storing/notifying.
Code reviewFound 5 issues:
Lines 5 to 7 in 41904e1
Lines 86 to 95 in 41904e1 Lines 30 to 50 in 41904e1
yatzcli/.github/workflows/release.yml Lines 16 to 18 in 41904e1
Lines 44 to 48 in 41904e1 Lines 114 to 118 in 41904e1
Lines 67 to 78 in 41904e1 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
1. Scorecard JSON serialization: add MarshalJSON/UnmarshalJSON so scorecard data survives P2P state transfer (unexported field fix) 2. RemoteClient deadlock: use expectResponse flag so state_updates during host's turn are drained instead of blocking responseCh 3. release.yml Go version: update from 1.22 to 1.24 to match go.mod 4. MCP hold_dice schema: change WithString to WithArray with integer items so real MCP clients send proper arrays 5. ReadMessage max size: reject messages over 1 MB to prevent OOM from malicious peers
Code review (re-review after fixes)Previous 5 issues were all addressed. Found 2 remaining issues:
Lines 147 to 155 in adae23b
Lines 131 to 140 in adae23b Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
1. MsgError in RemoteClient.listen() now checks expectResponse before sending to responseCh, matching the MsgStateUpdate fix and preventing listener deadlock from unsolicited error messages. 2. Remove Limit:1 from DynamoDB Scan in matchmaking handler. DynamoDB applies Limit before FilterExpression, so Limit:1 could return 0 results even when matching players exist.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
- Validate category names in Score() against AllCategories - Validate dice indices in Hold() are within 0-4 range - Skip zero-value dice in counts() to prevent false Yahtzee on unrolled dice - Panic on NewGame with 0 players to prevent index-out-of-range
Summary
GameClientinterface abstracting local/remote playOld client/server/game/messages/network packages removed and replaced entirely.
Test plan
go test -short ./...runs unit tests only (E2E skipped)go test ./...runs all tests including E2E (MCP full game, P2P full game)go build ./cmd/yatz/produces working binaryyatz playlaunches local game against AIyatz mcpstarts MCP server (verify with Claude Code)