proto/noitu/v1/game.proto is the single source of truth for every WebSocket message. buf generates Go types into server/gen and JavaScript types into web/src/lib/proto; both trees are committed so building needs no codegen toolchain. The Go suite emits binary fixtures into proto/testdata and the JavaScript suite decodes the same bytes, so the two generated clients are checked against one artifact rather than against each other's assumptions. CI lints the schema, rejects breaking changes against main, and fails when the committed generated trees drift from the schema. game.NumRejectReasons and game.NumEndReasons let the mapping tests prove every engine reason has a wire value without guessing where the enum ends.
11 KiB
title, status, phase, priority, effort, dependencies
| title | status | phase | priority | effort | dependencies | |
|---|---|---|---|---|---|---|
| Phase 3: Go Game Engine and Bot AI | done | 3 | P1 | 4d |
|
Phase 3: Go Game Engine and Bot AI
Overview
Pure, transport-free game logic: rule validation, per-game state, win/loss resolution, and the bot's move selection at three difficulties. No WebSocket, no protobuf, no timers driven by wall clock — the engine takes a deadline as data so it stays fully unit-testable.
Requirements
Functional
Engine.Submit(playerID, raw)validates: at least 2 syllables → first syllable matches current → word exists → not already used- Distinct rejection reasons, not a boolean
- Used-word set per game; a word is spent for both players
- Detects
no legal move remainsfor the player to act - Turn deadline held as
time.Time, enforced by the caller; engine exposesIsExpired(now) - Bot difficulties: Easy (random), Medium (prefer low out-degree), Hard (dead-end win + depth-limited search)
- Score model: points per accepted word, chain length tracked
Non-functional
- Zero dependency on
wsapior generated protobuf types — engine speaks its own Go types - Hard bot move decision ≤ 150ms
- Engine is safe under a single owning goroutine per game (documented; the room owns it)
Architecture
The game is a directed graph: nodes = syllables, edges = words (pháp ──"pháp luật"──► luật).
A move traverses an unused edge from the current node. Formally this is Directed Edge
Geography, which is PSPACE-complete — so no perfect solver is attempted; the Hard bot uses
an instant-win check plus bounded search. Words of 3+ syllables are edges like any other —
they simply span extra syllables between first and last, so nothing in the graph model
changes.
type RejectReason int
const (
ReasonNone RejectReason = iota
ReasonTooFewSyllables // fewer than vietnamese.MinSyllables (2)
ReasonWrongLink
ReasonNotInDictionary
ReasonAlreadyUsed
ReasonNotYourTurn
ReasonTimeout
)
type Engine struct {
dict *dictionary.Store
used map[string]struct{}
current string // syllable the next word must start with
turn PlayerID
deadline time.Time
history []Move
scores map[PlayerID]int
}
func New(dict *dictionary.Store, players []PlayerID, opening string, turnLimit time.Duration) (*Engine, error)
func (e *Engine) Submit(p PlayerID, raw string) (Move, RejectReason)
func (e *Engine) LegalMoves() ([]string, error) // for the player to act
func (e *Engine) HasLegalMove() (bool, error)
func (e *Engine) IsExpired(now time.Time) bool
func (e *Engine) Snapshot() State
Validation order matters — check cheapest and most-informative first so the player gets the most useful message: turn → syllable count (≥2) → link → dictionary → reuse.
Scoring and word length: longer words are worth more, so allowing 3+ syllables adds a reason to reach for them rather than being merely permissive. See the scoring formula below.
Bot strategies (internal/bot), all implementing one interface:
type Strategy interface{ Choose(e *game.Engine) (string, error) }
| Difficulty | Behaviour |
|---|---|
| Easy | Uniform random legal move. Adds a 400-900ms simulated "thinking" pause so it feels human |
| Medium | Score each legal move by the out-degree of the syllable it hands the opponent; pick randomly among the lowest quartile. Never plays a guaranteed kill |
| Hard | 1) if any legal move lands on a syllable with 0 remaining continuations → play it (instant win). 2) else negamax with alpha-beta, depth 4, over the remaining edge set; eval = -log(1 + opponent legal move count). 3) else fall back to Medium |
Search cost is bounded by move ordering (ascending opponent out-degree first) and a node cap; the branching factor is the out-degree of visited syllables, typically < 50.
Anti-frustration: Hard plays the instant-win move only hardKillRate of the time
(a tunable constant, default 0.85). A bot that always wins is not a game.
Scoring: 10 + 2 × chainLength + 5 × (syllables − 2) per accepted word, capped; final
score reported at game over. The syllable term rewards longer compounds now that they are
legal. Client persists a personal best in localStorage (phase 6) — no server storage.
Revised during implementation — measured, not assumed
| Spec said | Now | Why |
|---|---|---|
| Validation order: turn → length → link → dictionary | turn → length → dictionary → link → reuse | Phase 2 established that 38% of aliases move the first syllable (sỹ hai → sĩ hai). The link can only be checked after resolving, or legal moves get rejected. Messages stay accurate either way |
dict *dictionary.Store |
Dictionary interface in game |
Lets the engine and bot be tested on hand-built word graphs small enough to reason about, with no SQLite |
LegalMoves() ([]string, error), HasLegalMove() (bool, error) |
no error returned | Phase 2's store is in-memory; these cannot fail |
| Medium never plays a guaranteed kill | Medium takes a win it can see | Simulation: withholding it made Medium lose to the random bot 97% of the time. A strategy that declines to win loses to a coin flip. Difficulty now comes from lookahead depth, not from refusing to play well |
Eval -log(1 + opponent move count) |
+log(1 + moves) at the node |
Negamax evaluates from the perspective of the player to move, so the sign inverts. As specified, Hard searched for positions where it was about to be trapped and lost to Medium 71% of the time |
| Bot sleeps a "thinking" pause | ThinkingDelay() reported, caller schedules |
Sleeping inside Choose would make every test wait in real time |
Strategies take *game.Engine |
Board interface (read-only) |
A strategy can read the position but cannot play a move, so it cannot bypass Submit validation |
Measured on the real 48,216-word corpus (60 games per pairing, alternating sides):
| Matchup | Win rate | Mean game length |
|---|---|---|
| Hard vs Easy | 98% | 3.3 moves |
| Medium vs Easy | 87% | — |
| Hard vs Medium | 65% | 2.9 moves |
| Easy vs Easy | — | 15.3 moves |
Hard decision latency on the real corpus: p95 12.5ms, max 18.6ms against a 150ms budget.
Sides alternate because moving first is an advantage in its own right, and fixed seating reports that advantage as skill — on a 7-syllable graph with fixed sides the Hard-vs-Medium figure was exactly 50%, pure first-move effect.
How much lookahead is worth is a property of the graph, not of the bots. The synthetic ladder gave 78% on one seed and 49% on another, so the synthetic test now only guards against a gross regression (>= 45%) and the real-corpus test carries the claim. Earlier drafts of this document quoted the synthetic 78% and a 6ms max; both were specific to a single run and are corrected above.
Playability signal for phase 6 — the dominant characteristic, not an edge case. Games
against Hard end in ~3 moves against ~15 for two random bots. The dictionary has 1,814
dead-end syllables, so an instant kill is available at roughly 42% of opening positions and
Hard simply takes one; the search runs in only about half of its decisions. hardKillRate
(0.85) softens this but does not remove it. Needs playtesting before release; the levers are
a lower kill rate and dead-end-aware opening selection, both tunable constants.
Related Code Files
- Create:
server/internal/game/engine.go - Create:
server/internal/game/state.go—Move,State,PlayerID,RejectReason - Create:
server/internal/game/engine_test.go - Create:
server/internal/bot/bot.go—Strategyinterface, difficulty registry, thinking delay - Create:
server/internal/bot/strategy_easy.go - Create:
server/internal/bot/strategy_medium.go - Create:
server/internal/bot/strategy_hard.go - Create:
server/internal/bot/bot_test.go - Create:
server/internal/bot/simulate_test.go— 100-game difficulty ladder assertion
Implementation Steps
state.go:PlayerID,Move{Player, Word, First, Last, Syllables, Points, At},RejectReasonwithString(),Statesnapshot struct.engine.New: seedusedwith the opening word, setcurrentto its last syllable, assign first turn.Submit: normalize viavietnamese.Normalize, then run the validation order above; on success record the move, add toused, advancecurrentandturn, reset deadline, add points.LegalMoves:dict.WordsStartingWith(current)minusused.HasLegalMoveshort-circuits on the first hit rather than materializing the slice.- Engine unit tests, one per rejection reason, plus: reuse blocked across both players, chain advances correctly, no-legal-move detection,
IsExpiredboundary. bot.Strategyinterface + registry keyed by difficulty; shared randomized thinking delay helper.- Easy: uniform pick. Medium: out-degree-of-result scoring, lowest-quartile random pick, explicit guard that skips a 0-out-degree kill.
- Hard: instant-win scan gated by
hardKillRate; negamax + alpha-beta depth 4 with move ordering and node cap; Medium fallback. simulate_test.go: 100 headless bot-vs-bot games per pairing; assert Hard beats Easy well above chance and Medium sits between. Deterministic via a seeded RNG.- Benchmark Hard's
Chooseto confirm ≤150ms on the real DB.
Success Criteria
go test ./internal/game/... ./internal/bot/... -racegreen- Every
RejectReasonhas a test that produces exactly it - A 1-syllable submission yields
ReasonTooFewSyllables; 3- and 4-syllable words are accepted and score the syllable bonus - A word played by either player cannot be replayed by the other
HasLegalMovereturns false on a synthetic dead-end board and the engine reports the correct winner- Hard win rate vs Easy > 70% over 100 seeded games; Medium strictly between Easy and Hard
BenchmarkHardChoose≤ 150ms/op against the realnoitu.db- Engine package imports no transport or protobuf package (verified by an import assertion test)
Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Depth-4 search too slow on hub syllables (high out-degree) | Benchmark exceeds 150ms | Node cap already present; reduce to depth 3 for syllables with out-degree > 200 — the depth is a tunable constant |
| Hard bot feels unbeatable | Playtest win rate ≈ 0% | Lower hardKillRate; it exists precisely for this dial |
| Bot picks obscure words that feel unfair | Playtest complaints | Out of scope for v1 (no frequency data in the derived DB); note as a post-v1 item requiring a frequency column |
| Engine mutated from two goroutines | -race failures once rooms land in phase 5 |
Ownership rule documented here and enforced in phase 5: exactly one goroutine per room owns its engine |