docs: add noi tu web game plan and dictionary research

Research the Vietnamese noi tu word-chain game and plan a 7-phase web
implementation: SvelteKit frontend, Go backend, WebSocket transport with
Protobuf framing, and a server-authoritative dictionary over SQLite.

The server validates every move so the browser never holds the wordlist,
which keeps player-vs-player cheat-resistant and lets the bot and PvP
modes share one rule implementation.

Records the validated decisions: words of two or more syllables linking
on first and last syllable, a 20s turn limit, user-typed nicknames, and
Docker deployment behind a reverse proxy.
This commit is contained in:
tiennm99 committed 2026-09-04 16:25:29 +07:00
1 parent ad6c6344dc
commit 582ba27354
9 files changed
+1482

No files matched your search

@@ -0,0 +1,146 @@
---
title: "Phase 1: Foundations and Data Pipeline"
status: todo
phase: 1
priority: P1
effort: "3d"
dependencies: []
---
# Phase 1: Foundations and Data Pipeline
## Overview
Stand up the repo skeleton and produce `data/noitu.db` — the derived, normalized Vietnamese
wordlist of words with **at least 2 syllables** — from the upstream `minhqnd/dictionary`
SQLite release, with full CC BY-SA 4.0 compliance. Nothing downstream can be built or
tested without this data.
## Requirements
**Functional**
- [x] Reproducible build: upstream `dictionary.db` → `data/noitu.db`, runnable by any contributor
- [x] Output contains only Vietnamese (`lang_code = 'vi'`) entries of **2 or more space-separated syllables**
- [x] All words NFC-normalized and lowercased
- [x] Tone-placement aliases resolved (`hoà`→`hòa`, `thuý`→`thúy`, `quí`→`quý`, …)
- [x] Precomputed `first`/`last` syllable columns and out-degree table for O(1) engine lookups
- [x] Build fails loudly if entry count falls below a floor (40,000) or any row has fewer than 2 syllables
- [x] `--max-syllables` flag available (default: no cap) so a phrase cap can be applied later without a code change
**Non-functional**
- [x] Output DB is a few MB, opens read-only, no writes at runtime
- [x] License artifacts are correct and consistent across all five locations
## Architecture
**Source:** [`github.com/minhqnd/dictionary`](https://github.com/minhqnd/dictionary) release
**v2.0.0** → [`dictionary.db`](https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db),
**179 MB** (verified via the GitHub API; not in git; 357k+ entries, 1,500+ language pairs).
Code MIT, **data CC BY-SA 4.0**.
We do **not** republish a derived dictionary — every build downloads this asset and derives
locally. See `plan.md` → Data Distribution for the CI and Docker consequences.
**Derived schema** (`data/noitu.db`):
```sql
CREATE TABLE words (
word TEXT PRIMARY KEY, -- normalized, e.g. "pháp luật"
first TEXT NOT NULL, -- first syllable, "pháp"
last TEXT NOT NULL, -- last syllable, "luật"
syllables INTEGER NOT NULL -- 2, 3, 4, …
) WITHOUT ROWID;
CREATE INDEX idx_words_first ON words(first);
-- Out-degree per syllable: how many words start with it. Drives bot heuristics
-- and instant dead-end detection.
CREATE TABLE syllables (
syllable TEXT PRIMARY KEY,
out_degree INTEGER NOT NULL
) WITHOUT ROWID;
-- Accepted spelling variants that map to a canonical word. Built offline so the
-- runtime never has to reason about Vietnamese tone placement.
CREATE TABLE aliases (
variant TEXT PRIMARY KEY,
canonical TEXT NOT NULL REFERENCES words(word)
) WITHOUT ROWID;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
-- rows: source_url, source_license, built_at, word_count, builder_version
```
**Why an alias table instead of a runtime normalizer:** Vietnamese old-style vs new-style
tone placement (`hoà` vs `hòa`) produces genuinely different codepoint sequences. A runtime
algorithm to re-place tone marks is fiddly and easy to get subtly wrong; generating both
variants once at build time is data, cheap to fix, and trivially testable. KISS.
**Pipeline:**
```
dictionary.db ──► filter lang_code='vi'
──► NFC normalize (golang.org/x/text/unicode/norm) + lowercase + collapse spaces
──► keep len(strings.Fields(w)) >= 2 (and <= --max-syllables when set)
──► drop entries containing digits, latin-only tokens, or punctuation
──► dedupe
──► generate tone-placement variants → aliases
──► compute first/last/syllables + out_degree
──► write noitu.db + meta
──► assert: count >= 40000, every row >= 2 syllables, no orphan aliases
```
**Note on the graph:** allowing 3+ syllable words does not change the graph model — an edge
still runs `first ──word──► last`; it may simply span more syllables in between. Out-degree,
dead-end detection, and the engine's lookups are unaffected.
## Related Code Files
- Create: `server/cmd/build-dictionary/main.go` — CLI: `--in dictionary.db --out data/noitu.db [--max-syllables N]`
- Create: `server/cmd/build-dictionary/filter.go` — vi + ≥2-syllable + junk filters
- Create: `server/cmd/build-dictionary/aliases.go` — tone-placement variant generation
- Create: `server/cmd/build-dictionary/main_test.go` — filter/alias unit tests with fixtures
- Create: `data/LICENSE` — CC BY-SA 4.0 full text
- Create: `data/ATTRIBUTION.md`
- Create: `NOTICE`
- Create: `README.md` (replace stub) — quickstart, architecture, **License** section
- Create: `.gitignore` — `data/*.db` (covers both the 179 MB upstream and the derived DB), `web/node_modules`, `web/build`, server binaries
- Create: `Makefile` (or `Taskfile.yml`) — `make fetch-dict`, `make dict`, `make server`, `make web`, `make test`
- Create: `server/go.mod` — `module github.com/tiennm99dev/noitu/server`, Go 1.25+ (raised by modernc.org/sqlite; local toolchain is 1.26.5)
- Modify: none (repo is empty apart from `LICENSE` + `README.md`)
## Implementation Steps
1. `.gitignore`, `Makefile`, `server/go.mod` (`go mod init`), directory skeleton per plan layout.
2. Write `data/LICENSE` (verbatim CC BY-SA 4.0) and `data/ATTRIBUTION.md` naming: source repo + release URL, upstream sources (Wiktionary, `vntk/dictionary`), license URL, and the explicit modification list from `plan.md`.
3. Write `NOTICE` + README **License** section stating the split: Apache-2.0 code / CC BY-SA 4.0 data, and that the two are distributed as separate artifacts.
4. `server/cmd/build-dictionary`: open upstream DB read-only via `modernc.org/sqlite`, stream `vi` words.
5. Normalization: `norm.NFC`, `strings.ToLower`, `strings.Fields` → join with single space.
6. Filters: at least 2 fields (and ≤ `--max-syllables` when set); reject any token containing a digit, ASCII-only letters, or punctuation.
7. Alias generation: for each canonical word, emit old-style tone-placement variants for the `oa/oe/uy` nuclei; skip a variant if it collides with a different canonical word (log collisions).
8. Compute `first`, `last`, `syllables`, `out_degree`; write output DB in one transaction; write `meta` rows.
9. Assertions: word count ≥ 40,000; 100% of rows ≥ 2 syllables; every alias resolves; fail non-zero otherwise.
10. `make fetch-dict` downloads the 179 MB asset from the pinned release URL into `data/` (resumable, checksum-logged); `make dict` derives from it. Both documented in the README as one-time setup.
11. Unit tests on fixtures: normalization, ≥2-syllable filter (including a 3- and a 4-syllable entry accepted and a 1-syllable rejected), junk rejection, alias generation, collision handling.
## Success Criteria
- [x] `make fetch-dict && make dict` produces `data/noitu.db` from the upstream release
- [x] `sqlite3 data/noitu.db "SELECT COUNT(*) FROM words"` ≥ 40,000
- [x] Zero rows where `word` has fewer than 2 syllables; 3- and 4-syllable entries present
- [x] `hoà`, `thuý`, `quí` each resolve through `aliases` to a canonical word present in `words`
- [x] `meta` records source URL, license, build timestamp, word count
- [x] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section all present and mutually consistent
- [x] `go test ./cmd/...` green
- [x] `data/*.db` is git-ignored
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Upstream schema differs from README-documented shape | Query errors on first run | Inspect actual tables with `.schema` first; adapt the extraction query — the pipeline stages after extraction are schema-independent |
| Fewer than 40k clean ≥2-syllable entries | Count assertion fails | Union with [Viet74K](https://vietnamese-wordlist.duyet.net/Viet74K.txt) filtered to ≥2 syllables; add it as a second `--in` source and extend `ATTRIBUTION.md` |
| 179 MB download is slow or the release URL moves | `make fetch-dict` fails or stalls | URL is pinned to release `v2.0.0`, download is resumable, and the derived DB is cached locally so the fetch is a one-time cost per contributor |
| Allowing 3+ syllables admits multi-word phrases that are not really words | Playtest complaints | `--max-syllables` flag already present; applying a cap is a rebuild, not a code change |
| Upstream data contains proper nouns / non-words that make the game feel wrong | Playtest complaints in later phases | Add a denylist file consumed by the builder; regenerate — no code change needed |
| Alias generation creates false accepts (a variant that is a different real word) | Collision log non-empty | Collisions are skipped by design and logged; review the log before release |
| CC BY-SA obligations misread | License review | Attribution + share-alike on the data artifact only; code untouched. `NOTICE` states the boundary explicitly |
@@ -0,0 +1,108 @@
---
title: "Phase 2: Go Dictionary and Normalization"
status: todo
phase: 2
priority: P1
effort: "2d"
dependencies: [1]
---
# Phase 2: Go Dictionary and Normalization
## Overview
The read-side of the dictionary: a Go package that normalizes arbitrary player input the
same way the builder normalized the corpus, and a read-only SQLite store exposing the three
lookups the game engine needs. Everything above this layer treats words as opaque strings.
## Requirements
**Functional**
- [ ] `vietnamese.Normalize(raw) (word string, syllables []string, err error)` — NFC, lowercase, whitespace collapse, syllable split (any syllable count; length rules belong to the engine)
- [ ] Store resolves a player-typed variant to its canonical word via `aliases`
- [ ] `Store.Lookup(word)` → canonical word + exists
- [ ] `Store.WordsStartingWith(syllable)` → words (for bot move generation)
- [ ] `Store.OutDegree(syllable)` → int (0 means dead end)
- [ ] Store opens the DB **read-only**; concurrent-safe for many goroutines
**Non-functional**
- [ ] Lookup ≤ 1ms p99 under 100 concurrent readers
- [ ] Normalization identical to the builder's — shared code path, not a reimplementation (DRY)
## Architecture
`internal/vietnamese` is imported by **both** `server/cmd/build-dictionary` and the server, so the
corpus and player input can never diverge. Phase 1 writes the builder against this package;
this phase hardens and tests it.
```go
// internal/vietnamese
func Normalize(raw string) (string, []string, error) // NFC + lower + collapse + split
const MinSyllables = 2
func HasEnoughSyllables(sylls []string) bool // len(sylls) >= MinSyllables
// internal/dictionary
type Store struct{ db *sql.DB }
func Open(path string) (*Store, error) // file:...?mode=ro&_pragma=busy_timeout(5000)
func (s *Store) Resolve(word string) (canonical string, ok bool, err error)
func (s *Store) WordsStartingWith(syl string) ([]string, error)
func (s *Store) OutDegree(syl string) (int, error)
func (s *Store) RandomOpeningWord(minOutDegree int) (string, error)
func (s *Store) Close() error
```
**Resolve order:** exact hit in `words` → else `aliases` lookup → else not found.
**The engine chains on the canonical word, never on the alias the player typed.**
An alias can differ from its canonical in the last syllable (`chức vỵ` vs `chức vị`), so
chaining on raw input would demand a next word linking from a syllable that is not in the
dictionary. `Resolve` therefore returns the canonical form, the engine records that, and the
client displays it — the player sees their word normalized to its dictionary spelling.
One prepared statement per query, held on the `Store`; `database/sql` handles pooling.
**Hot-path caching:** `OutDegree` is read on every bot move. Load the whole `syllables`
table into a `map[string]int` at `Open` (a few tens of thousands of entries, ~1MB) and serve
from memory. `WordsStartingWith` stays on SQL — the result sets are small and per-turn.
**Opening word selection:** pick uniformly from words whose `last` syllable has
`out_degree >= minOutDegree`, so a game never dies on move one.
## Related Code Files
- Create: `server/internal/vietnamese/normalize.go`
- Create: `server/internal/vietnamese/normalize_test.go`
- Create: `server/internal/dictionary/store.go`
- Create: `server/internal/dictionary/store_test.go`
- Create: `server/internal/dictionary/testdata/mini.db` — small fixture DB built by a test helper
- Modify: `server/go.mod` — add `modernc.org/sqlite`, `golang.org/x/text`
- Modify: `server/cmd/build-dictionary/main.go` — import `internal/vietnamese` instead of local normalization
## Implementation Steps
1. Implement `Normalize`: `norm.NFC.String` → `strings.ToLower` → `strings.Fields` → rejoin; return error on empty input.
2. Point the phase-1 builder at `internal/vietnamese` so one normalizer serves both sides.
3. `dictionary.Open`: DSN with `mode=ro`, verify `meta` table exists and log `word_count` + `source_license` at startup (license visibility).
4. Prepared statements for `Resolve` (words), `Resolve` (aliases), `WordsStartingWith`.
5. Load `syllables` into an in-memory map at open; `OutDegree` reads the map.
6. `RandomOpeningWord` via `ORDER BY RANDOM() LIMIT 1` over a joined out-degree filter.
7. Test helper that builds a tiny fixture DB in `t.TempDir()` from a hardcoded word set — no dependency on the real 60k DB in unit tests.
8. Tests: NFC equivalence (composed vs decomposed `ữ`), uppercase input, tabs/NBSP/multiple spaces, 1-syllable rejected while 3- and 4-syllable accepted, alias resolution for `hoà`/`thuý`/`quí`, unknown word, dead-end syllable returns 0.
9. Concurrency test: 100 goroutines × 1000 `Resolve` calls, `-race` clean.
## Success Criteria
- [ ] `go test ./internal/... -race` green
- [ ] Composed and decomposed spellings of the same word both resolve to one canonical entry
- [ ] Words of 2, 3, and 4 syllables all resolve; a 1-syllable input is rejected by `HasEnoughSyllables`
- [ ] `hoà lợi`-style tone variants resolve via `aliases`
- [ ] Store refuses to open a missing or writable-mode DB path with a clear error
- [ ] Startup log line names the data source and license
- [ ] Builder and server share one normalization implementation (no duplicate NFC/lowercase logic)
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| `modernc.org/sqlite` read performance disappoints under load | p99 lookup > 1ms in the concurrency test | Widen the in-memory cache: load `words` into a `map[string]string` too (~60k entries, a few MB) and use SQL only for cold paths |
| Normalization drift between builder and server | A word in the DB fails to match the same string typed by a player | Prevented structurally by the shared package; a regression test asserts round-trip on a sample of real DB rows |
| `strings.Fields` mishandles an exotic Unicode space | Rare rejection reports | `strings.Fields` already splits on all `unicode.IsSpace`; test explicitly covers NBSP and tab |
@@ -0,0 +1,144 @@
---
title: "Phase 3: Go Game Engine and Bot AI"
status: todo
phase: 3
priority: P1
effort: "4d"
dependencies: [2]
---
# 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 remains` for the player to act
- [ ] Turn deadline held as `time.Time`, enforced by the caller; engine exposes `IsExpired(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 `wsapi` or 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.
```go
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:
```go
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.
## 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` — `Strategy` interface, 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
1. `state.go`: `PlayerID`, `Move{Player, Word, First, Last, Syllables, Points, At}`, `RejectReason` with `String()`, `State` snapshot struct.
2. `engine.New`: seed `used` with the opening word, set `current` to its last syllable, assign first turn.
3. `Submit`: normalize via `vietnamese.Normalize`, then run the validation order above; on success record the move, add to `used`, advance `current` and `turn`, reset deadline, add points.
4. `LegalMoves`: `dict.WordsStartingWith(current)` minus `used`. `HasLegalMove` short-circuits on the first hit rather than materializing the slice.
5. Engine unit tests, one per rejection reason, plus: reuse blocked across both players, chain advances correctly, no-legal-move detection, `IsExpired` boundary.
6. `bot.Strategy` interface + registry keyed by difficulty; shared randomized thinking delay helper.
7. Easy: uniform pick. Medium: out-degree-of-result scoring, lowest-quartile random pick, explicit guard that skips a 0-out-degree kill.
8. Hard: instant-win scan gated by `hardKillRate`; negamax + alpha-beta depth 4 with move ordering and node cap; Medium fallback.
9. `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.
10. Benchmark Hard's `Choose` to confirm ≤150ms on the real DB.
## Success Criteria
- [ ] `go test ./internal/game/... ./internal/bot/... -race` green
- [ ] Every `RejectReason` has 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
- [ ] `HasLegalMove` returns 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 real `noitu.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 |
@@ -0,0 +1,170 @@
---
title: "Phase 4: Protobuf Contract and Codegen"
status: todo
phase: 4
priority: P1
effort: "2d"
dependencies: [1]
---
# Phase 4: Protobuf Contract and Codegen
## Overview
One `.proto` file defines every message crossing the WebSocket, and `buf` generates both the
Go server types and the JavaScript client types from it. No hand-written message structs on
either side — the schema is the single source of truth for the wire contract.
Independent of phases 2-3; can be built in parallel with them.
## Requirements
**Functional**
- [ ] `proto/noitu/v1/game.proto` covers handshake with nickname, create/join room, start bot game, submit word, move results, turn state, game over, errors, heartbeat
- [ ] `buf generate` emits Go into `server/gen/noitu/v1` and JS into `web/src/lib/proto`
- [ ] Protocol version field in the handshake; server rejects mismatched majors with a readable error
- [ ] Generated code is committed (reviewable diffs, no toolchain needed to build)
**Non-functional**
- [ ] JS bundle cost of the protobuf runtime kept small — ESM, tree-shakeable
- [ ] `buf lint` and `buf breaking` (against `main`) run in CI
## Architecture
**Tooling:** [`buf`](https://buf.build) drives both targets from one config.
- Go: `protoc-gen-go` (messages only — no gRPC, this is raw WS framing)
- JS: [`@bufbuild/protobuf`](https://github.com/bufbuild/protobuf-es) **v2.14.1** +
`@bufbuild/protoc-gen-es` **v2.14.1** (both verified current on npm), generated with
`target=js` plus `.d.ts`. Chosen over `protobuf.js`: it is the only fully
conformance-compliant JS implementation, ships ESM for tree-shaking, and produces a much
smaller browser bundle.
> `buf` is **not installed** in the current environment (`buf: command not found`). This does
> not block building or running: generated code is committed, so `buf` is required only when
> the schema changes. Install it before step 1.
**Framing:** every WS frame is a binary message — `ClientMessage` client→server,
`ServerMessage` server→client, each a single `oneof`. No JSON fallback, no text frames.
```proto
syntax = "proto3";
package noitu.v1;
option go_package = "github.com/tiennm99dev/noitu/server/gen/noitu/v1;noituv1";
enum Difficulty { DIFFICULTY_UNSPECIFIED = 0; EASY = 1; MEDIUM = 2; HARD = 3; }
enum RejectReason {
REJECT_UNSPECIFIED = 0;
TOO_FEW_SYLLABLES = 1; // fewer than 2 syllables
WRONG_LINK = 2;
NOT_IN_DICTIONARY = 3;
ALREADY_USED = 4;
NOT_YOUR_TURN = 5;
TIMEOUT = 6;
}
enum GameEndReason {
END_UNSPECIFIED = 0;
END_TIMEOUT = 1;
END_NO_LEGAL_MOVE = 2;
END_OPPONENT_LEFT = 3;
END_RESIGNED = 4;
}
message Hello { uint32 protocol_version = 1; string resume_token = 2; string nickname = 3; }
message StartBotGame { Difficulty difficulty = 1; }
message CreateRoom {}
message JoinRoom { string room_code = 1; }
message SubmitWord { string word = 1; uint32 turn_seq = 2; } // turn_seq guards double-submits
message Resign {}
message Ping { int64 client_time_ms = 1; }
message ClientMessage {
oneof payload {
Hello hello = 1; StartBotGame start_bot_game = 2; CreateRoom create_room = 3;
JoinRoom join_room = 4; SubmitWord submit_word = 5; Resign resign = 6; Ping ping = 7;
}
}
// accepted_nickname is what the server actually stored after sanitization — the client
// must display this, not the string it sent.
message Welcome { string session_id = 1; string resume_token = 2; uint32 protocol_version = 3; string accepted_nickname = 4; }
message RoomCreated { string room_code = 1; }
message RoomJoined { string room_code = 1; string opponent_name = 2; }
message PlayedWord { string word = 1; bool by_me = 2; uint32 points = 3; uint32 syllables = 4; }
message GameStarted {
string opening_word = 1; string current_syllable = 2; bool my_turn = 3;
int64 deadline_unix_ms = 4; uint32 turn_seq = 5; uint32 turn_limit_ms = 6;
}
message TurnUpdate {
PlayedWord played = 1; string current_syllable = 2; bool my_turn = 3;
int64 deadline_unix_ms = 4; uint32 turn_seq = 5;
uint32 my_score = 6; uint32 opponent_score = 7; uint32 chain_length = 8;
}
message MoveRejected { RejectReason reason = 1; string word = 2; uint32 turn_seq = 3; }
message GameOver { bool i_won = 1; GameEndReason reason = 2; uint32 my_score = 3; uint32 chain_length = 4; }
message OpponentLeft { bool can_reconnect = 1; uint32 grace_ms = 2; }
message ServerError { string code = 1; string message = 2; } // message is a UI key, not prose
message Pong { int64 client_time_ms = 1; int64 server_time_ms = 2; }
message ServerMessage {
oneof payload {
Welcome welcome = 1; RoomCreated room_created = 2; RoomJoined room_joined = 3;
GameStarted game_started = 4; TurnUpdate turn_update = 5; MoveRejected move_rejected = 6;
GameOver game_over = 7; OpponentLeft opponent_left = 8; ServerError error = 9; Pong pong = 10;
}
}
```
**Design notes**
- `TurnUpdate` is sent to **both** players after every accepted move, with `by_me`/`my_turn`
rendered per recipient — the server serializes one message per player, not a broadcast.
- `deadline_unix_ms` is an absolute server timestamp; the client renders a countdown from it.
`Ping`/`Pong` carry both clocks so the client can correct for offset. Never trust the client clock.
- `turn_seq` on `SubmitWord` makes double-submits and late submissions detectable server-side.
- `ServerError.message` carries a **UI key** (e.g. `room_not_found`), not user-facing prose —
the Vietnamese strings live in the frontend so all copy stays in one place.
- `Hello.nickname` is a **request**, not a fact. The server sanitizes it (phase 5) and returns
the stored value in `Welcome.accepted_nickname`; `RoomJoined.opponent_name` is always a
server-sanitized string. A client must never render another player's raw input.
- The engine's `RejectReason` and this proto enum are deliberately separate types with an
explicit mapping function; the wire contract must not be hostage to internal refactors.
## Related Code Files
- Create: `proto/noitu/v1/game.proto`
- Create: `buf.yaml`, `buf.gen.yaml`, `buf.lock`
- Create: `server/gen/noitu/v1/game.pb.go` (generated, committed)
- Create: `web/src/lib/proto/game_pb.js` (generated, committed)
- Create: `server/internal/wsapi/convert.go` — engine ↔ proto enum mapping + tests
- Modify: `Makefile` — `make proto`
- Modify: `server/go.mod` — `google.golang.org/protobuf`
- Modify: `web/package.json` — `@bufbuild/protobuf`, dev dep `@bufbuild/protoc-gen-es`
## Implementation Steps
1. `buf.yaml` (module + lint/breaking config) and `buf.gen.yaml` with the two plugins and their output dirs.
2. Write `game.proto` as above; `buf lint` clean.
3. `make proto` → `buf generate`; commit both generated trees.
4. `convert.go`: `game.RejectReason` → `noituv1.RejectReason`, engine end reasons → `GameEndReason`. Exhaustive `switch` with a default that returns the UNSPECIFIED value **and** logs — a silently-dropped new reason is a bug.
5. Round-trip test in Go: marshal each `ServerMessage` variant, unmarshal, assert equality.
6. Round-trip test in JS (Vitest): decode a fixture emitted by the Go test, assert field values — proves cross-language wire compatibility, not just self-consistency.
7. CI step: `buf lint`, `buf breaking --against '.git#branch=main'`, and a check that regenerating produces no diff (generated code stays in sync).
## Success Criteria
- [ ] `make proto` regenerates both targets with zero diff on a clean tree
- [ ] `buf lint` clean; `buf breaking` wired into CI
- [ ] Go round-trip test covers every `oneof` variant in both directions
- [ ] JS decodes a Go-produced binary fixture and reads correct values
- [ ] `convert.go` mapping is exhaustive; a test fails if an engine reason gains a value with no proto counterpart
- [ ] No hand-written message struct exists in `server/` or `web/`
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Schema churn breaks an already-deployed client | Decode errors after a deploy | Additive-only changes, never reuse a tag, `reserved` on removals; `protocol_version` in `Hello` lets the server reject incompatible clients with a clear message instead of failing obscurely |
| `buf` unavailable in a contributor's environment | `make proto` fails locally | Generated code is committed, so building and running never requires `buf` — only changing the schema does |
| Protobuf runtime inflates the JS bundle | Bundle budget exceeded in phase 6 | `protobuf-es` is ESM/tree-shakeable and only the generated messages are imported; measure in phase 6 and drop to hand-rolled binary framing only if it genuinely fails the budget |
| Enum drift between engine and wire | A new reject reason silently arrives as UNSPECIFIED | Exhaustive-switch test plus a logged default in `convert.go` |
@@ -0,0 +1,145 @@
---
title: "Phase 5: Go WebSocket Server and Rooms"
status: todo
phase: 5
priority: P1
effort: "4d"
dependencies: [3, 4]
---
# Phase 5: Go WebSocket Server and Rooms
## Overview
The transport and orchestration layer: a WebSocket endpoint that speaks Protobuf frames,
a hub owning all live rooms, per-room goroutines driving the engine and turn timers, and
the bot wired in as a virtual player. After this phase the whole game is playable over
`websocat` with no frontend.
## Requirements
**Functional**
- [ ] `GET /ws` upgrades and runs the session loop; `GET /healthz` for liveness
- [ ] Bot rooms (1 human + bot) and PvP rooms (2 humans, joined by 6-char room code)
- [ ] Nicknames sanitized server-side and echoed back; opponents only ever see sanitized values
- [ ] Server-authoritative 20s turn timer (`NOITU_TURN_LIMIT`); expiry ends the game for the player on turn
- [ ] Every accepted move fans out a per-recipient `TurnUpdate`
- [ ] Bot moves scheduled on the room goroutine after a randomized thinking delay
- [ ] Session resume token issued in `Welcome`, honoured on reconnect within a grace window
- [ ] Serves the built frontend as static files so one binary is the whole deployment
**Non-functional**
- [ ] One goroutine owns each room's engine — no shared mutable game state (`-race` clean)
- [ ] Per-connection read limit, read/write deadlines, and rate limiting on `SubmitWord`
- [ ] Graceful shutdown drains rooms and tells clients why
## Architecture
```
┌──────── hub (mutex-guarded maps) ────────┐
conn ──► session ──chan──► room goroutine ──► game.Engine
│ │ └► bot.Strategy
│ └── time.Timer (turn deadline)
└── writer goroutine (owns the socket write side)
```
**Concurrency contract — the core invariant of this phase:**
- Each connection has exactly **one reader goroutine** and **one writer goroutine**. Nothing
else touches the socket. (`coder/websocket` tolerates concurrent writes, but a single
writer keeps ordering deterministic.)
- Each room has exactly **one goroutine** that owns its `game.Engine`. All input arrives as
messages on the room's channel. The engine is never locked because it is never shared.
- The hub owns only the `code → *room` and `sessionID → *session` maps, guarded by a mutex.
It never touches engine state.
**Room lifecycle**
```
CreateRoom → generate code (6 chars, unambiguous alphabet: no 0/O/1/I) → room waits
JoinRoom → second player attaches → engine created → GameStarted to both
StartBotGame→ room created immediately with a bot as player 2 → GameStarted
in play → SubmitWord | timer fire | disconnect
GameOver → both notified → room lingers briefly for a rematch, then hub evicts it
```
**Turn timer:** the room goroutine holds a `time.Timer` for the current deadline, selected
on alongside the input channel. On fire: mark the player on turn as the loser
(`END_TIMEOUT`) and broadcast `GameOver`. The absolute deadline also goes to clients in
`TurnUpdate.deadline_unix_ms` — clients render a countdown but never decide the outcome.
**Bot integration:** the bot is a player ID with no connection. When it is the bot's turn,
the room schedules `Choose` on a worker and delivers the result back through the same input
channel as a human move, so it flows through identical validation. If the bot has no legal
move, the human wins.
**Reconnect:** `Welcome` carries a `resume_token`. On disconnect, the room keeps the seat
open for `graceMs` (default 30s) and sends the opponent `OpponentLeft{can_reconnect}`. A
`Hello` carrying a valid token rebinds the seat and replays current state via `GameStarted`
+ the latest `TurnUpdate`. Timeout ends the game as `END_OPPONENT_LEFT`.
**Security / abuse limits**
- `conn.SetReadLimit(4096)` — no legitimate message approaches this (verified API: `SetReadLimit(n int64)`)
- Read deadlines come from `context.WithTimeout` passed to `conn.Read` — `coder/websocket`
has **no** `SetReadDeadline`; everything is context-driven. Ping via `conn.Ping(ctx)` every
20s, treating a ctx timeout as a miss and closing after 2
- `SubmitWord` rate-limited to ~5/sec per session (a submit is one dictionary lookup)
- Room codes from `crypto/rand`; join attempts rate-limited to make brute-forcing pointless
- Origin check via `websocket.AcceptOptions.OriginPatterns` from `NOITU_ALLOWED_ORIGINS`;
never set `InsecureSkipVerify` outside local dev
- **Nickname sanitization** (`sanitizeNickname`): NFC normalize, strip control characters and
zero-width joiners, collapse whitespace, trim, cap at 20 runes, reject empty → assign
`Người chơi N`. Nicknames are shown to strangers, so this is a real input-validation
boundary, not cosmetic. A denylist hook is left in place for post-v1 if abuse appears
- Every rejection returns a UI key, never a raw internal error string
## Related Code Files
- Create: `server/cmd/noitu-server/main.go` — flags/env, dict open, hub, HTTP mux, graceful shutdown
- Create: `server/internal/wsapi/server.go` — upgrade handler, origin check, static file serving
- Create: `server/internal/wsapi/session.go` — reader/writer goroutines, codec, resume tokens
- Create: `server/internal/wsapi/hub.go` — room + session registries, room code generation
- Create: `server/internal/wsapi/room.go` — room goroutine, timer, bot scheduling, fan-out
- Create: `server/internal/wsapi/codec.go` — protobuf marshal/unmarshal over binary frames
- Create: `server/internal/wsapi/ratelimit.go`
- Create: `server/internal/wsapi/nickname.go` — `sanitizeNickname` + tests
- Create: `server/internal/wsapi/room_test.go`, `session_test.go`, `hub_test.go`
- Modify: `server/go.mod` — `github.com/coder/websocket`
- Modify: `Makefile` — `make server`, `make run`
## Implementation Steps
1. `main.go`: config from env (`NOITU_ADDR`, `NOITU_DB_PATH`, `NOITU_TURN_LIMIT` default `20s`, `NOITU_ALLOWED_ORIGINS`, `NOITU_WEB_DIR`), open the dictionary read-only, construct hub, mount `/ws`, `/healthz`, and static assets, `signal.NotifyContext` shutdown.
2. `codec.go`: `Encode(*ServerMessage) []byte` / `Decode([]byte) (*ClientMessage, error)`; binary message type only, reject text frames.
3. `session.go`: reader goroutine (read limit, per-read `context.WithTimeout`, decode, forward to the room or hub) and writer goroutine (buffered channel, single owner of writes). Generate `session_id` and `resume_token` with `crypto/rand`; sanitize `Hello.nickname`; send `Welcome` carrying `accepted_nickname`; reject mismatched `protocol_version`.
4. `hub.go`: registries + mutex; `CreateBotRoom`, `CreateRoom`, `JoinRoom(code)`, `ResumeSession(token)`, eviction of finished/idle rooms via a janitor ticker.
5. `room.go`: the goroutine — `select` over input channel, turn timer, and context done. Handle `SubmitWord` (validate turn_seq, call engine, fan out per-recipient `TurnUpdate` or `MoveRejected`), `Resign`, disconnect, timer fire, bot scheduling, and no-legal-move detection after every move.
6. Per-recipient rendering: build `TurnUpdate` twice, flipping `by_me`/`my_turn` and swapping scores. Do not broadcast one shared message.
7. Bot wiring: on bot turn, run `Choose` on a worker goroutine with the room's context, deliver via the input channel after the thinking delay; cancel cleanly if the room ends first.
8. Reconnect: grace timer per seat, `OpponentLeft` to the other player, state replay on successful resume, `END_OPPONENT_LEFT` on grace expiry.
9. `ratelimit.go`: token bucket per session for `SubmitWord` and per IP for room joins.
10. Tests with in-process WS clients: full bot game to completion; two clients play a PvP game; timeout ends the game; reuse and wrong-link rejections reach the client with the right enum; disconnect+resume within grace restores state; resume after grace fails cleanly. All under `-race`.
11. Manual smoke via `websocat` documented in the README.
## Success Criteria
- [ ] `go test ./internal/wsapi/... -race` green
- [ ] A full bot game is playable end to end over a raw WS client, no frontend involved
- [ ] Two in-process clients complete a PvP game with correct alternating turns and a correct winner
- [ ] Turn timeout ends the game server-side even if the client sends nothing
- [ ] Disconnect + resume within 30s restores the game; after 30s the opponent wins by `END_OPPONENT_LEFT`
- [ ] Read limit, rate limit, and origin check each covered by a test
- [ ] `sanitizeNickname` tested against: over-length, control chars, zero-width chars, whitespace-only, and empty input; opponents never receive an unsanitized string
- [ ] `go build ./cmd/noitu-server` yields one binary that serves both `/ws` and the static frontend
- [ ] Graceful shutdown notifies connected clients rather than dropping sockets silently
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Engine touched from two goroutines | `-race` failure | The one-goroutine-per-room rule is structural: the engine is only reachable through the room's channel. A review checklist item, not a runtime guard |
| Timer and message race (move accepted exactly at the deadline) | Flaky test, disputed loss | Resolve entirely inside the room goroutine: the timer fire and the move arrive on the same `select`, so one strictly precedes the other. `turn_seq` makes the outcome explicit to the client |
| Bot goroutine outlives its room | Goroutine leak under load test | Room context cancels the worker; a leak test asserts goroutine count returns to baseline after N games |
| Room code collision | Join lands in the wrong room | Generate with `crypto/rand` and retry on collision inside the hub mutex |
| Restart drops all live games | Players lose in-progress matches on deploy | Accepted for v1 (documented in `plan.md`); graceful shutdown sends a typed error so the UI can explain it |
| Offensive nicknames shown to strangers | Abuse reports | Sanitization caps length and strips control/zero-width characters; a denylist hook is already in `nickname.go` for a data-only fix if it becomes a real problem |
| Reconnect replay diverges from actual state | Resumed client shows a stale board | Replay is a fresh snapshot from the engine, never a stored copy of past messages |
@@ -0,0 +1,143 @@
---
title: "Phase 6: SvelteKit Frontend"
status: todo
phase: 6
priority: P1
effort: "5d"
dependencies: [4, 5]
---
# Phase 6: SvelteKit Frontend
## Overview
The playable UI: a SvelteKit SPA in JavaScript that connects over WebSocket, speaks
Protobuf, and renders the game. Vietnamese throughout, dark mode, local high score. This
phase delivers the complete vs-bot experience; phase 7 layers the online 1v1 flows on top.
## Requirements
**Functional**
- [ ] Home screen: nickname input, play vs bot (Easy / Trung bình / Khó), or go to online play
- [ ] Nickname persisted in `localStorage`, sent in `Hello`, and replaced by `accepted_nickname` from `Welcome`
- [ ] Game screen: chain history, current syllable prompt, input, countdown ring, both scores
- [ ] Rejections shown as specific Vietnamese messages, mapped from `RejectReason`
- [ ] Game-over screen: result, final score, new-personal-best marker, rematch / home
- [ ] Dark mode toggle, persisted; respects `prefers-color-scheme` on first visit
- [ ] Personal best per difficulty in `localStorage`
- [ ] Connection status indicator; automatic reconnect with backoff
- [ ] In-app footer credit for the CC BY-SA 4.0 dictionary source, with links
**Non-functional**
- [ ] `adapter-static` build, served by the Go binary
- [ ] Mobile-first; usable one-handed on a phone with the keyboard up
- [ ] No wordlist or dictionary data in the client bundle
- [ ] Vietnamese diacritic input (Telex/VNI IMEs) works — no input interception that breaks composition
## Architecture
**Stack:** SvelteKit 2 + Svelte 5 (runes: `$state`, `$derived`, `$effect`), plain JavaScript
with JSDoc types, `adapter-static`, Vite. Scaffold with `npx sv create`.
```
web/src/
lib/
proto/ # generated (phase 4) — do not hand-edit
ws/client.js # socket lifecycle, protobuf codec, reconnect backoff, ping/pong
ws/messages.js # thin senders: startBotGame(), submitWord(), createRoom(), joinRoom()
stores/game.svelte.js # $state game model, fed only by ServerMessage
stores/settings.svelte.js# nickname + theme + high scores, localStorage-backed
i18n/vi.js # ALL user-facing strings, incl. RejectReason → message map
components/ # ChainHistory, WordInput, CountdownRing, ScoreBoard, DifficultyPicker,
# ConnectionBadge, GameOverPanel, ThemeToggle, AttributionFooter
routes/
+layout.svelte # theme application, footer
+page.svelte # home
play/+page.svelte # game screen (bot and PvP share it)
online/+page.svelte # room create/join (phase 7 fills this in)
```
**State rule:** the store is a projection of server messages. The client never decides
whether a word is valid, whose turn it is, or who won — it renders what the server sent.
The only client-owned state is theme, personal best, and the input box.
**Countdown:** derived from `deadline_unix_ms` minus a clock offset measured by `Ping`/`Pong`,
re-evaluated on `requestAnimationFrame`. Purely cosmetic — the server decides expiry.
**Reconnect:** exponential backoff (0.5s → 8s, jittered). On reconnect send `Hello` with the
stored `resume_token`; the server either restores the game or returns a typed error and the
UI falls back to the home screen with an explanation.
**Vietnamese input:** bind on `change`/submit and read `event.target.value`; never
re-write the input's value mid-composition, and listen for `compositionstart`/`compositionend`
before any normalization — rewriting the field while a Telex IME is composing corrupts
diacritic entry. Normalization is the server's job anyway.
**i18n:** every string lives in `lib/i18n/vi.js`, including the `RejectReason` map:
| Reason | Message |
|---|---|
| `TOO_FEW_SYLLABLES` | "Từ phải có ít nhất 2 tiếng." |
| `WRONG_LINK` | "Từ phải bắt đầu bằng tiếng \"{syllable}\"." |
| `NOT_IN_DICTIONARY` | "Không tìm thấy từ này trong từ điển." |
| `ALREADY_USED` | "Từ này đã được dùng rồi." |
| `NOT_YOUR_TURN` | "Chưa đến lượt bạn." |
| `TIMEOUT` | "Hết giờ!" |
**Theming:** CSS custom properties on `:root`, `[data-theme="dark"]` override, set from the
settings store before first paint to avoid a flash.
## Related Code Files
- Create: `web/` — SvelteKit scaffold (`package.json`, `svelte.config.js`, `vite.config.js`, `jsconfig.json`)
- Create: `web/src/lib/ws/client.js`, `web/src/lib/ws/messages.js`
- Create: `web/src/lib/stores/game.svelte.js`, `web/src/lib/stores/settings.svelte.js`
- Create: `web/src/lib/i18n/vi.js`
- Create: `web/src/lib/components/*.svelte` (per list above)
- Create: `web/src/routes/+layout.svelte`, `+page.svelte`, `play/+page.svelte`, `online/+page.svelte`
- Create: `web/src/app.css` — tokens, light/dark palettes
- Create: `web/src/lib/ws/client.test.js`, `web/src/lib/stores/game.test.js` (Vitest)
- Modify: `Makefile` — `make web`, `make web-dev`
- Modify: `server/internal/wsapi/server.go` — serve `NOITU_WEB_DIR` with SPA fallback
## Implementation Steps
1. Scaffold with `npx sv create web` (SvelteKit 2 / Svelte 5, JavaScript, Vitest, ESLint+Prettier); switch to `adapter-static` with `fallback: 'index.html'`.
2. Wire `make proto` output into `web/src/lib/proto`; add `@bufbuild/protobuf`.
3. `ws/client.js`: connect (`ws://` in dev via Vite proxy, same-origin `wss://` in prod), binary frames, decode to `ServerMessage`, dispatch to the store, ping/pong clock offset, backoff reconnect, `resume_token` in `sessionStorage`.
4. `stores/game.svelte.js`: `$state` model (phase, chain, currentSyllable, myTurn, deadline, scores, lastRejection, gameOver); one reducer per `ServerMessage` variant.
5. `stores/settings.svelte.js`: nickname, theme (system default, then explicit), `bestScore[difficulty]`, all `localStorage`-backed with try/catch so private-mode browsing still works.
6. Components: `ChainHistory` (scrolling word list, most recent pinned and highlighted, syllable-count badge on 3+ syllable words), `WordInput` (IME-safe, disabled when not your turn, autofocus on turn start), `CountdownRing` (SVG arc from the derived remaining time, colour shift under 5s), `NicknameInput` (20-char cap, mirrors the server rule), `ScoreBoard`, `DifficultyPicker`, `ConnectionBadge`, `GameOverPanel`, `ThemeToggle`, `AttributionFooter`.
7. Routes: home (nickname + mode + difficulty), `play` (the board, works for bot now and PvP in phase 7), `online` stub.
8. `i18n/vi.js` with every string and the rejection map; assert in a test that each proto `RejectReason` has an entry.
9. Personal best: on `GameOver`, compare and store per difficulty; show a "kỷ lục mới" marker.
10. `AttributionFooter`: names `minhqnd/dictionary`, links the repo and the CC BY-SA 4.0 deed — the user-visible half of the license obligation.
11. Vitest: store reducers for each server message, reconnect backoff schedule, rejection-map completeness, high-score persistence with `localStorage` throwing.
12. Verify the production bundle contains no word data (`grep` the built assets for known words) and record the bundle size.
## Success Criteria
- [ ] Full vs-bot game playable in a browser at all three difficulties
- [ ] Words of 2, 3, and 4 syllables are all accepted and scored, with the syllable bonus visible
- [ ] Nickname persists across reloads; the displayed name is always `accepted_nickname` from the server, never the raw input
- [ ] Every rejection reason renders its specific Vietnamese message
- [ ] Countdown matches the server deadline within ~200ms; expiry is announced by the server, not the client
- [ ] Dark mode persists across reloads with no flash of the wrong theme
- [ ] Personal best per difficulty persists and updates
- [ ] Killing the server mid-game shows a connection state and reconnects when it returns
- [ ] Diacritics typed with a Telex IME enter correctly on desktop and mobile
- [ ] `npm run build` → `adapter-static` output served correctly by the Go binary, deep links included
- [ ] Built assets contain no dictionary words
- [ ] Attribution footer present with working links
- [ ] `npm test` green
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| IME composition broken by input handling | Diacritics mangled while typing | Never rewrite the input value during composition; guard with `compositionstart`/`end`. Explicitly tested on a real mobile keyboard, not just a desktop emulator |
| Countdown drift makes a legitimate move look late | Player sees 2s left, server says timeout | Clock offset from `Ping`/`Pong`; the ring visually settles ~300ms early so the client never claims more time than the server allows |
| Svelte 5 runes in `.svelte.js` store files misused | Reactivity silently stops updating | Stores use the documented `.svelte.js` rune-module pattern; store reducers are unit-tested outside components |
| Protobuf runtime bloats the bundle | Bundle budget exceeded | Measured in step 12; `protobuf-es` is tree-shakeable and only generated messages are imported |
| `localStorage` unavailable (private mode / blocked) | Crash on load | Every read/write wrapped in try/catch with a working in-memory default |
| Dev/prod WS URL divergence | Works in `npm run dev`, breaks when served by Go | Same-origin `wss://` resolved from `location` in prod; Vite proxy only in dev; both paths exercised before phase 7 |
@@ -0,0 +1,144 @@
---
title: "Phase 7: Online 1v1 and Release"
status: todo
phase: 7
priority: P1
effort: "4d"
dependencies: [5, 6]
---
# Phase 7: Online 1v1 and Release
## Overview
Complete the online 1v1 experience end to end in the browser — room codes, waiting room,
opponent presence, reconnect UX, rematch — then verify the whole game with browser E2E tests
and ship it: CI, container image, deployment, and documentation.
## Requirements
**Functional**
- [ ] Create a room → shareable 6-character code and a copyable invite link
- [ ] Join by code, or by opening an invite link with the code prefilled
- [ ] Waiting room until the opponent arrives; leaving cleans the room up
- [ ] Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only)
- [ ] Live opponent state: their turn, their timer, their disconnect and reconnect
- [ ] Resign, and rematch in the same room after a game ends
- [ ] Clear Vietnamese error states: room not found, room full, game already started
**Non-functional**
- [ ] E2E coverage of both modes with two real browser contexts
- [ ] CI: Go tests + race, JS tests, `buf lint`/`breaking`, generated-code drift check, builds
- [ ] Deployable artifact: container image with the binary, the static frontend, and `noitu.db`
- [ ] README documents setup, the license split, and deployment
## Architecture
No new server concepts — phase 5 already implements rooms, codes, reconnect, and resign.
This phase is the frontend surface for them plus release engineering.
**Invite link:** `https://<host>/online?code=ABC123`. The `online` route reads the query
param, prefills, and auto-joins when the code is well-formed. Room codes use an unambiguous
alphabet (no `0/O/1/I`) and are displayed grouped for reading aloud.
**Online screen states**
```
idle ──create──► waiting (code shown, copy button)
│ └──opponent joins──► playing
└──join(code)──► playing | error(room_not_found | room_full | already_started)
playing ──opponent disconnects──► banner "Đối thủ mất kết nối… (Ns)"
├─ they return ──► resume playing
└─ grace expires ──► GameOver(END_OPPONENT_LEFT)
```
**Rematch:** after `GameOver` in a PvP room, either player may request a rematch; the room
resets its engine with a fresh opening word when both accept, or closes on decline/timeout.
Requires a small additive protocol change — `RequestRematch` / `RematchState` — which is why
it lands here rather than in phase 4: additive tags only, per the phase-4 compatibility rule.
**Deployment — Docker image on a VPS behind a reverse proxy (validated decision):**
```
Dockerfile (multi-stage)
node → npm ci && npm run build → /web/build
go → CGO_ENABLED=0 go build ./cmd/... → /noitu-server
data → curl the 179 MB dictionary.db → build-dictionary → /data/noitu.db
final → distroless/static + binary + /web + /data/noitu.db + /data/LICENSE + ATTRIBUTION
```
`CGO_ENABLED=0` works because the SQLite driver is `modernc.org/sqlite` (pure Go) — the
payoff for that phase-1 decision. The 179 MB upstream download happens **only in the data
builder stage**, so it never reaches the final image; `noitu.db` is copied in as its own
layer, keeping the CC BY-SA 4.0 artifact physically distinct from the Apache-2.0 binary.
**CI data strategy (consequence of not republishing a derived DB):** CI must never download
179 MB per run.
| Job | Data used |
|---|---|
| Unit tests (every push/PR) | Small fixture DB built in-test from a checked-in word sample (phase 2 helper) |
| E2E tests (every push/PR) | Same fixture DB, ~200 curated words — enough to script deterministic games |
| Full dictionary build (manual / nightly) | Real upstream, with the download cached by release tag |
| Docker image build (release only) | Real upstream, inside the builder stage |
## Related Code Files
- Modify: `proto/noitu/v1/game.proto` — add `RequestRematch`, `RematchState` (new tags only)
- Modify: `server/gen/...`, `web/src/lib/proto/...` — regenerate
- Modify: `server/internal/wsapi/room.go` — rematch handling, room reset
- Modify: `web/src/routes/online/+page.svelte` — replace the phase-6 stub with the full create/join/waiting/error flow
- Create: `web/src/lib/components/RoomCodePanel.svelte`, `WaitingRoom.svelte`, `OpponentStatus.svelte`, `RematchPrompt.svelte`
- Modify: `web/src/lib/stores/game.svelte.js` — room/opponent/rematch state
- Modify: `web/src/lib/i18n/vi.js` — online-mode strings
- Create: `e2e/bot-game.spec.js`, `e2e/pvp-game.spec.js`, `e2e/reconnect.spec.js` (Playwright)
- Create: `playwright.config.js`
- Create: `Dockerfile`, `.dockerignore`
- Create: `.github/workflows/ci.yml`
- Modify: `README.md` — quickstart, architecture, deployment, license split
- Create: `docs/deployment.md`
## Implementation Steps
1. Add `RequestRematch` / `RematchState` to the proto with new tags; regenerate both targets; extend the round-trip tests.
2. Server: rematch in `room.go` — both-accept resets the engine with a new opening word and re-emits `GameStarted`; decline or timeout closes the room. Cover with a room test.
3. `online/+page.svelte`: the state machine above, code input (uppercase, alphabet-restricted, paste-friendly), `?code=` prefill and auto-join.
4. `RoomCodePanel`: large grouped code, copy button, copy invite link, Web Share on mobile where available.
5. `WaitingRoom`: opponent-pending state with a cancel that leaves the room cleanly.
6. `OpponentStatus`: turn indicator, disconnect banner with the grace countdown, reconnect confirmation.
7. `RematchPrompt` on the game-over panel for PvP; bot mode keeps a plain "chơi lại".
8. Vietnamese strings for every new state, including the three join errors.
9. Playwright setup: build the frontend, run the real server against the **~200-word fixture DB** (never the real dictionary — CI must not download 179 MB), run specs against it.
10. E2E `bot-game.spec.js`: pick Hard, play scripted valid and invalid words, assert rejection messages, play to game over, assert the score and personal-best marker.
11. E2E `pvp-game.spec.js`: two browser contexts with different nicknames, create + join by code, assert each side shows the other's sanitized nickname, alternate turns, assert each side sees the other's word, resign ends it correctly.
12. E2E `reconnect.spec.js`: drop one context's socket mid-game, assert the opponent's disconnect banner, restore within grace, assert the board matches on both sides.
13. `Dockerfile` multi-stage as above; verify the image runs with only `NOITU_DB_PATH` set.
14. `ci.yml`: `go vet`, `go test ./... -race`, `npm test`, `npm run build`, `buf lint`, `buf breaking`, generated-code drift check, Playwright — all on the fixture DB. Separate release-only job builds the Docker image. Add an assertion that the built image contains `data/LICENSE` and `data/ATTRIBUTION.md`.
15. `README.md`: what the game is, quickstart (**`make fetch-dict` downloads 179 MB once**, then `make dict`), `make` targets, architecture diagram, **License** section (Apache-2.0 code / CC BY-SA 4.0 data with attribution), and a link to `data/ATTRIBUTION.md`.
16. `docs/deployment.md`: env vars (incl. `NOITU_TURN_LIMIT=20s`, `NOITU_ALLOWED_ORIGINS`), TLS/`wss://` behind a reverse proxy (`Upgrade`/`Connection` headers, proxy read timeout longer than the WS keepalive, buffering disabled), health check, and how `noitu.db` reaches the container.
## Success Criteria
- [ ] Two people on different machines play a full game via a shared room code
- [ ] Invite link opens straight into the room
- [ ] All three join errors show correct Vietnamese messages
- [ ] Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win
- [ ] Rematch restarts in the same room with a new opening word
- [ ] Resign ends the game immediately with the right winner
- [ ] Playwright suite green: bot game, PvP game, reconnect
- [ ] CI green on all steps including the generated-code drift check, without ever downloading the 179 MB upstream DB
- [ ] `docker run` with `NOITU_DB_PATH` serves a playable game; the image contains `data/LICENSE` and `data/ATTRIBUTION.md` but not the 179 MB upstream file
- [ ] README license section and in-app attribution agree with `data/ATTRIBUTION.md`
- [ ] Full pass over `plan.md` success criteria — every box checkable
## Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Reverse proxy breaks WS upgrade in production | Works locally, 400/502 on deploy | `docs/deployment.md` states the required proxy headers and timeouts; the health check plus a post-deploy WS smoke test catch it before users do |
| Playwright PvP tests flake on timing | Intermittent CI red | Drive by deterministic server events (wait for the turn indicator), never fixed sleeps; run the suite with a long turn limit via env |
| Rematch protocol addition breaks phase-6 clients | Decode errors in an already-running client | Additive tags only; `buf breaking` in CI is the guard, and `protocol_version` lets the server reject a stale client cleanly |
| Room codes guessable enough to join a stranger's game | Reports of uninvited joins | `crypto/rand` codes plus per-IP join rate limiting from phase 5; 32^6 space with rate limiting makes scanning impractical |
| CC BY-SA obligations lost during packaging | Image ships without `data/LICENSE` | The Docker copy step includes the whole `data/` directory, and a CI assertion checks `data/LICENSE` and `data/ATTRIBUTION.md` exist in the built image |
| Scope creep into accounts/leaderboards at the finish line | New requests during release work | Explicit non-goals in `plan.md`; log them as post-v1 items instead |
+281
View File
@@ -0,0 +1,281 @@
---
title: "Noi Tu Web Game"
description: "Vietnamese nối từ web game — SvelteKit frontend, Go backend, WebSocket + Protobuf, server-authoritative dictionary over SQLite. Vs-bot and online 1v1."
status: pending
priority: P1
effort: "~3-4w"
tags: [game, sveltekit, go, websocket, protobuf, sqlite, vietnamese]
created: 2026-09-04
blockedBy: []
blocks: []
---
# Noi Tu Web Game
## Overview
Web implementation of **nối từ**, the Vietnamese word-chain game: a player submits a
meaningful word of **at least 2 syllables** whose **first syllable equals the previous
word's last syllable**. No reuse, timed turns. Loss on timeout, invalid word, wrong link,
repeat, or no legal move remaining.
Two modes ship in v1: **vs bot** (3 difficulties) and **online 1v1**. Both run through the
same server-authoritative engine — the browser never holds the wordlist, so validation
cannot be bypassed and the bot and PvP paths share one code path.
**Stack (user-selected):** SvelteKit (JavaScript) frontend · Go backend · WebSocket
transport with Protobuf framing · dictionary served from SQLite server-side.
**Research basis:** [`plans/reports/research-260904-1058-noi-tu-game.md`](../reports/research-260904-1058-noi-tu-game.md)
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Correct, server-authoritative nối từ rules incl. Vietnamese text normalization | P1 |
| 2 | Reproducible dictionary pipeline from `minhqnd/dictionary` with correct CC BY-SA 4.0 compliance | P1 |
| 3 | Typed WS protocol (Protobuf) shared by Go and JS, single source of truth | P1 |
| 4 | Playable vs-bot mode with 3 difficulty levels | P1 |
| 5 | Online 1v1 with nicknames, room codes, turn timer, reconnect | P1 |
| 6 | Vietnamese UI, dark mode, local high score | P2 |
| 7 | Single deployable Go binary, shipped as a Docker image | P2 |
## Non-goals (v1)
- Word definitions / meanings display (explicitly out of scope per user selection)
- Accounts, auth, persistent profiles, server-side leaderboards
- 4-player *đấu trường* mode
- Native mobile apps
- Player-submitted word additions / dictionary moderation UI
- Redistributing our derived dictionary as a downloadable artifact (see Data Distribution)
## Architecture
```
┌──────────────────────────────┐ ┌────────────────────────────────────┐
│ SvelteKit SPA (adapter- │ WSS │ Go server (single binary) │
│ static, Svelte 5 runes) │ ◄─────► │ │
│ │ Protobuf│ wsapi ── hub ── room(s) │
│ lib/ws connection+codec │ binary │ │ │
│ lib/proto protobuf-es gen │ frames │ ├── game.Engine │
│ lib/stores game state │ │ └── bot.Bot │
│ routes/ UI (vi, dark mode)│ │ │ │
└──────────────────────────────┘ │ dictionary.Store ──┘ │
localStorage: nickname, high score, │ │ read-only SQL │
theme │ data/noitu.db (CC BY-SA 4.0) │
└────────────────────────────────────┘
▲ built locally by
server/cmd/build-dictionary (Go)
▲ reads
dictionary.db (179 MB, downloaded from
minhqnd/dictionary release v2.0.0)
```
**Key decisions**
| Decision | Choice | Rationale |
|---|---|---|
| Authority | Server validates every move; client is a view | User chose server-side dictionary; also the only way PvP is cheat-resistant |
| Word length | **≥ 2 syllables**, link on first↔last syllable | User decision. Closer to casual play; keeps 3+ syllable compounds in the corpus |
| Bot location | Server-side, same engine as PvP | One rule implementation, no duplication (DRY); bot "thinking delay" is server-timed |
| Turn limit | **20s**, identical for bot and PvP | One constant, one code path; `NOITU_TURN_LIMIT` overrides |
| Player identity | **User-typed nickname**, no accounts | User decision. Stored in `localStorage`, sanitized server-side |
| WS library (Go) | `github.com/coder/websocket` v1.8.15 (ISC) | `gorilla/websocket` archived 2022 and panics on concurrent writes; coder/websocket is context-aware and handles concurrent writes |
| Protobuf (JS) | `@bufbuild/protobuf` v2.14.1 + `protoc-gen-es` | Only fully conformant JS impl, ESM/tree-shakeable, small browser bundle |
| Protobuf (Go) | `protoc-gen-go` via same `buf.gen.yaml` | One schema, two targets, generated in CI |
| Go module path | `github.com/tiennm99dev/noitu/server` | Matches the actual git remote |
| SQLite driver | `modernc.org/sqlite` (CGo-free) | Keeps `CGO_ENABLED=0` cross-compilation and a distroless image; read-only lookups are not CGo-bound |
| Dictionary DB | Loaded at runtime from `data/noitu.db`, **not** `go:embed` | Keeps CC BY-SA 4.0 data a separate artifact from Apache-2.0 code — cleanest license boundary |
| Tone variants | Alias table built offline (`hoà`→`hòa`) | Precomputed lookup beats a fragile runtime tone-placement algorithm (KISS) |
| Room state | In-memory, no DB | v1 has no persistence requirement; restart drops live games (accepted, documented) |
| Deployment | Docker image on a VPS behind a reverse proxy | Most portable; phase 7 ships a distroless image and proxy docs |
## Data Distribution
**Decision (validated):** we do **not** republish a derived dictionary. Every build downloads
`dictionary.db` from the upstream release and derives `data/noitu.db` locally.
Upstream asset: [`minhqnd/dictionary` release v2.0.0 → `dictionary.db`](https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db) — **179 MB**.
Consequences, all handled explicitly:
| Consequence | Handling |
|---|---|
| 179 MB is too large to fetch on every CI run | CI runs tests against a **small fixture DB** built from a checked-in word sample (phase 2). Only a manually-triggered/nightly job downloads the real upstream and builds the full DB |
| Docker build needs the upstream DB | Downloaded in the **builder stage** only; the final image carries just the derived `noitu.db`, so the 179 MB never ships |
| `data/noitu.db` is a local build artifact | Git-ignored. `make dict` is a documented prerequisite for running the real server |
| The derived DB *is* distributed inside our Docker image | CC BY-SA 4.0 still applies to that image layer — attribution files ship with it, asserted in CI (phase 7) |
## Licensing (mandatory — CC BY-SA 4.0 share-alike)
`minhqnd/dictionary` is **dual-licensed**: MIT for application code, **CC BY-SA 4.0 for the
dictionary data**. We consume only the data, so share-alike applies to our derived wordlist
wherever we distribute it (notably the Docker image).
| Artifact | License | File |
|---|---|---|
| All source code in this repo | Apache-2.0 (existing) | `LICENSE` |
| Derived dictionary `data/noitu.db` and the build script's output | **CC BY-SA 4.0** | `data/LICENSE` |
| Attribution + list of modifications | — | `data/ATTRIBUTION.md` |
| Root pointer to the split | — | `NOTICE`, README section |
| User-visible credit | — | in-app footer with link |
Attribution must name: `minhqnd/dictionary`, its own upstream sources (Wiktionary, `vntk/dictionary`),
the CC BY-SA 4.0 license URL, and **what we changed** (filtered to Vietnamese entries of ≥2
syllables, NFC-normalized, tone-variant aliases added, first/last syllable columns and indexes
added, all non-`vi` languages and all definitions/translations dropped).
## Phases
| # | Phase | Status | Depends on |
|---|-------|--------|-----------|
| 1 | [Foundations and Data Pipeline](./phase-01-foundations-and-data-pipeline.md) | Pending | — |
| 2 | [Go Dictionary and Normalization](./phase-02-go-dictionary-and-normalization.md) | Pending | 1 |
| 3 | [Go Game Engine and Bot AI](./phase-03-go-game-engine-and-bot-ai.md) | Pending | 2 |
| 4 | [Protobuf Contract and Codegen](./phase-04-protobuf-contract-and-codegen.md) | Pending | 1 |
| 5 | [Go WebSocket Server and Rooms](./phase-05-go-websocket-server-and-rooms.md) | Pending | 3, 4 |
| 6 | [SvelteKit Frontend](./phase-06-sveltekit-frontend.md) | Pending | 4, 5 |
| 7 | [Online 1v1 and Release](./phase-07-online-1v1-and-release.md) | Pending | 5, 6 |
Phases 2-3 and 4 are independent after phase 1 and can run in parallel if desired.
## Target Repository Layout
```
LICENSE # Apache-2.0 — code only
NOTICE # points at data/ licensing
README.md
buf.yaml buf.gen.yaml
proto/noitu/v1/game.proto # single protocol source of truth
data/
LICENSE # CC BY-SA 4.0 full text
ATTRIBUTION.md
dictionary.db # upstream, 179 MB, git-ignored, downloaded
noitu.db # derived build artifact, git-ignored
server/cmd/build-dictionary/ # Go: dictionary.db -> noitu.db
server/ # module github.com/tiennm99dev/noitu/server
go.mod
cmd/noitu-server/main.go
internal/vietnamese/ # normalize.go, syllable.go
internal/dictionary/ # store.go — read-only SQLite lookups
internal/game/ # engine.go, state.go, rules.go
internal/bot/ # bot.go, strategy_*.go
internal/wsapi/ # hub.go, room.go, session.go, codec.go
gen/noitu/v1/ # protoc-gen-go output
web/
src/lib/proto/ # protobuf-es output
src/lib/ws/ # socket client, reconnect, codec
src/lib/stores/ # game state (Svelte 5 runes)
src/lib/components/
src/routes/
```
## Success Criteria
- [ ] `server/cmd/build-dictionary` reproducibly turns the upstream `dictionary.db` into `data/noitu.db`; entry count and ≥2-syllable purity asserted
- [ ] `data/LICENSE`, `data/ATTRIBUTION.md`, `NOTICE`, README license section, and in-app credit all present and consistent
- [ ] Go engine unit tests cover: wrong link, unknown word, reuse, single-syllable input, timeout, no-legal-move, and the tone-variant cases `hoà/hòa`, `thuý/thúy`, `quí/quý`
- [ ] Words of 2, 3, and 4 syllables all accepted and chain correctly on first↔last syllable
- [ ] One `.proto` generates working Go and JS clients; no hand-written message types
- [ ] Vs-bot playable end to end at all 3 difficulties; Hard bot wins measurably more than Easy over 100 simulated games
- [ ] Online 1v1: two browsers join by room code with chosen nicknames, alternate turns, server-enforced 20s timer, correct win/loss, reconnect within grace window restores the game
- [ ] Vietnamese UI throughout; dark mode toggle persists; nickname and high score persist in localStorage
- [ ] `go build` produces one binary; `web` builds to static assets served by that binary
- [ ] No wordlist reachable from the client bundle (verified by inspecting the built assets)
- [ ] CI runs green without ever downloading the 179 MB upstream DB
## Risk Assessment
| Risk | Signal it happened | Response |
|---|---|---|
| Upstream `dictionary.db` yields too few clean ≥2-syllable entries (<40k) | Build script count assertion fails | Union with Viet74K multi-syllable filter (see research report §5) — decided before implementation, not mid-phase |
| Tone-variant aliasing misses real-world spellings | Players report valid words rejected | Alias table is data, not code: regenerate with an expanded rule set and rebuild `noitu.db`; add a rejected-word log to find gaps |
| CC BY-SA share-alike misapplied to code | License review flags the Apache/CC mix | Data stays a runtime-loaded separate artifact, never `go:embed`-linked; boundary documented in `NOTICE` and asserted in the image |
| Hard bot is unbeatable, players quit | Playtest win rate ≈0% vs Hard | Cap Hard's killer-syllable play rate; the difficulty ladder is tunable constants, not structure |
| In-memory rooms lost on deploy/restart | Live games drop | Accepted for v1 and stated in-app ("máy chủ đang khởi động lại"); persistence is a post-v1 item |
| Protobuf schema churn breaks a deployed client | Client decode errors after deploy | Additive-only field changes, reserved tags, version field in the handshake; server rejects unknown protocol versions with a clear message |
| Nicknames become an abuse surface (shown to strangers) | Offensive names reported | Server-side sanitization: length cap, control-char strip, whitespace collapse, empty → auto-name. A denylist is a cheap post-v1 addition if needed |
| Allowing 3+ syllable words admits phrases that feel wrong | Playtest complaints about unnatural entries | Builder supports a max-syllable cap and a denylist file; both are data changes, no code change |
## Validation Log
### Session 1 — 2026-09-04
**Verification Results**
- Tier: Full (7 phases)
- Claims checked: 14 · Verified: 11 · Failed: 1 · Imprecise: 2 · Unverified (env): 1
| Claim | Result | Evidence |
|---|---|---|
| proto `go_package` = `github.com/tiennm99/noitu/server/...` | **FAILED** | `git remote -v` → `github.com/tiennm99dev/noitu.git`. Corrected to `github.com/tiennm99dev/noitu/server` |
| `coder/websocket` provides `SetReadLimit`, `AcceptOptions.OriginPatterns`, `MessageBinary` | VERIFIED | pkg.go.dev, v1.8.15, ISC |
| "Read deadline per frame" via a socket deadline setter | **IMPRECISE** | coder/websocket exposes no `SetReadDeadline`; deadlines are `context.WithTimeout` on `Read`. Phase 5 corrected |
| `dictionary.db` downloadable from `minhqnd/dictionary` Releases | VERIFIED | GitHub API: release `v2.0.0`, asset `dictionary.db` |
| Upstream DB size assumed manageable | **IMPRECISE** | Asset is **179 MB**. Plan had no size claim; Data Distribution section added |
| `@bufbuild/protobuf` / `@bufbuild/protoc-gen-es` current | VERIFIED | npm registry, both v2.14.1 |
| Go ≥1.24, Node, Docker available locally | VERIFIED | go1.26.5, node v24.18.0, Docker 29.6.2 |
| `buf` installed locally | **UNVERIFIED (env gap)** | `buf: command not found`. Not blocking — generated code is committed, so `buf` is needed only to change the schema |
**Decisions confirmed**
| # | Question | Decision | Impact |
|---|---|---|---|
| 1 | Go module path | `github.com/tiennm99dev/noitu/server` | Phase 4 `go_package` corrected |
| 2 | Derived DB distribution | Do not republish — every build downloads upstream and derives locally | New Data Distribution section; phase 1 download step, phase 7 CI + Docker strategy |
| 3 | Turn time limit | 20s, identical for bot and PvP | Phase 5 default constant |
| 4 | Deployment target | Docker on a VPS behind a reverse proxy | Phase 7 docs scoped to proxy config, not a PaaS |
| 5 | Word length rule | **≥ 2 syllables** (changed from strict 2) | Phases 1, 2, 3, 4, 6 — filter, API, reject reason, proto enum, UI copy |
| 6 | Player identity in PvP | **User-typed nickname** | Phases 4, 5, 6, 7 — proto field, sanitization, input UI, display |
**Phase propagation:** phases 1-7 all updated. Reject reason renamed
`NOT_TWO_SYLLABLES` → `TOO_FEW_SYLLABLES` across the engine, proto, and Vietnamese copy.
### Whole-Plan Consistency Sweep
Re-read `plan.md` + all 7 phase files after propagation.
| Check | Result |
|---|---|
| Stale "2-syllable"/"exactly 2" claims | Resolved — all now "≥2 syllables" / "at least 2" |
| `NOT_TWO_SYLLABLES` enum name | Resolved — renamed everywhere (engine, proto, i18n) |
| `go_package` module path | Resolved — single correct value in phase 4 |
| Turn limit stated inconsistently | Resolved — 20s in plan.md + phase 5, `NOITU_TURN_LIMIT` override |
| Nickname referenced but never sourced | Resolved — proto field, sanitization, input, and display now specified |
| `online/+page.svelte` Create vs Modify across phases 6/7 | Resolved — phase 6 creates the stub, phase 7 modifies it |
| Data distribution vs Docker/CI steps | Resolved — consistent across plan.md, phase 1, phase 7 |
| Duplicate embedded proto schema | Single copy, in phase 4 only |
**Unresolved contradictions: none.**
## Phase 1 Outcome (2026-09-04)
Built and verified against the real upstream release.
| Metric | Value |
|---|---|
| Words | 48,216 (floor 40,000) |
| 2 / 3 / 4+ syllables | ~40k / ~4.5k / ~3.5k |
| Aliases | 2,041 |
| Derived DB | ~3 MB, from a 179 MB source |
| Coverage | `internal/vietnamese` 100%, `cmd/build-dictionary` 82.4% |
Upstream schema auto-detected (`words.word` / `lang_code`) with no flag override needed,
retiring the phase-1 "unknown upstream schema" risk. Upstream asset pinned by SHA-256.
**Four defects were found and fixed in alias generation, three of them only visible by
inspecting real output rather than fixture tests:** `quý`→`qúy`, `gì`→`gỳ`,
`hoàn`→`hòan` (tone shift must be confined to open syllables), and an i/y rule so broad it
invented ~340 non-words such as `chức vỵ`. A fifth, opposite defect was found by review:
varying one syllable at a time never produced `hóa lý`, the spelling most people type.
Variants are now generated as a cross product.
A Vietnamese phonotactic check (closed onset/nucleus/coda inventories) now rejects
loanwords the multilingual source tags as Vietnamese — `credit card`, `world cup`,
`come out`. Its first version wrongly rejected the entire `gì`/`gỉ`/`gìn` family by
greedily matching the `gi` digraph; it backtracks over onset candidates now.
## Open Questions
1. Does the losing player see the words the bot *could* have played (a teaching feature), or just the result? Plan currently assumes just the result.
2. Should the builder cap maximum syllables (e.g. reject 5+ syllable entries as phrases rather than words)? Plan currently applies no upper bound; the cap exists in the builder as a flag if playtesting says otherwise.
3. Domain name / TLS certificate source for the VPS deployment.
@@ -0,0 +1,201 @@
# Research Report: Vietnamese "Nối Từ" Word-Chain Game
Conducted: 2026-09-04 10:58 (Asia/Saigon) · Repo: `D:/tiennm99dev/noitu` (empty, initial commit)
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [Methodology](#methodology)
3. [Game Rules](#1-game-rules)
4. [Data Model & Core Algorithms](#2-data-model--core-algorithms)
5. [Bot AI](#3-bot-ai)
6. [Vietnamese Text Normalization](#4-vietnamese-text-normalization-the-real-bug-source)
7. [Dictionary Sources](#5-dictionary-sources-ranked)
8. [Implementation Recommendations](#6-implementation-recommendations)
9. [Common Pitfalls](#7-common-pitfalls)
10. [References](#references)
11. [Open Questions](#open-questions)
## Executive Summary
Nối từ = Vietnamese word-chain. Player says a **2-syllable** meaningful word; next player must say a 2-syllable word whose **first syllable equals the previous word's last syllable**. No reuse. Fail to answer in time → lose. Example: `ngôn ngữ → ngữ pháp → pháp luật → luật lệ`.
Implementation is trivial as a game loop; the hard 20% is (a) getting a clean 2-syllable Vietnamese word list, (b) Unicode/tone normalization, (c) a bot that doesn't feel dumb. Model it as a **directed graph**: nodes = syllables, edges = words (`a→b` for word "a b"). A move = traverse an unused edge from the current node. This is exactly **Directed Edge Geography** — PSPACE-complete, so no cheap perfect solver; use heuristic + depth-limited search.
Best data source: **`minhqnd/Noi-Tu-Discord` → `src/assets/wordPairs.json`** (MIT, ~60k word pairs, already keyed `firstSyllable → [lastSyllables]` — the exact index the game needs). Fallback/expansion: `duyet/vietnamese-wordlist` Viet74K (74k raw, filter to 2-syllable) and vi.wiktionary dumps.
## Methodology
- Sources: 4 web searches + 1 repo fetch (skill cap 5)
- Date range: 2020–2026; dictionaries current as of Jan 2026 (Wiktionary copy)
- Terms: `luật chơi nối từ`, `vietnamese wordlist github json`, `github bot nối từ thuật toán`, `Viet74K vietnamese two-syllable dataset`
---
## 1. Game Rules
**Core rule**: next word's first syllable == previous word's last syllable.
| Rule | Standard | Notes |
|---|---|---|
| Word length | exactly 2 syllables (từ ghép) | most online implementations enforce 2 strictly; some allow ≥2 |
| Validity | must exist in dictionary, meaningful | typically noun/adj compounds |
| Reuse | banned within a round | track a used-set |
| Timeout | 10–30s per turn | loss condition |
| Chain link | on **syllable**, not letter | despite folk phrasing "chữ cái cuối" |
| Start word | random or player-chosen | pick a high-out-degree node so game doesn't die instantly |
**Loss conditions**: timeout, invalid/unknown word, wrong first syllable, repeated word, no legal move remains.
**Modes seen in the wild** (Nối từ tiếng Việt app, gamevui, vuanoitu.fun, wordfight.online):
- *Thử thách* — solo, N rounds vs clock + target score
- *Thách đấu* — 1v1 alternating
- *Đấu trường* — 4 players round-robin
**Design decision needed early**: does the bot lose when it has no move (fair), or does it get to challenge a rare word? Most implementations: no move = bot loses.
## 2. Data Model & Core Algorithms
### Graph model
```
word "pháp luật" => edge pháp ──"pháp luật"──> luật
state = (currentSyllable, usedWords:Set)
legalMoves(s) = { w in adj[s] : w not in usedWords }
```
### Index structure (build once at load)
```js
// Map<firstSyllable, string[]> -- full words (or last syllables)
{ "pháp": ["pháp luật", "pháp lý", "pháp danh", ...], "luật": ["luật lệ", "luật sư", ...] }
```
Lookup O(1); validation O(1) with a `Set<string>` of full normalized words.
### Validation pipeline
```
input -> trim -> NFC -> lowercase -> collapse spaces
-> split on space; assert length === 2
-> assert syllables[0] === currentSyllable
-> assert dictionary.has(word)
-> assert !used.has(word)
```
### Dead-end precomputation
- `outDegree[syllable]` = number of words starting with it.
- **Killer syllables**: `outDegree === 0` (or very low) — rare endings. Precompute the list; moving there is an instant win.
- Syllables with outDegree 0 make the *next* player lose immediately. Mark them at build time.
## 3. Bot AI
Perfect play = Directed Edge Geography, PSPACE-complete → no exact solver at 60k edges. Practical ladder:
| Difficulty | Strategy |
|---|---|
| Easy | random legal move |
| Medium | prefer moves ending in a **low out-degree** syllable; avoid handing the player a hub |
| Hard | 1) instant win: any move to `outDegree==0`; 2) negamax depth 3–5 with alpha-beta over remaining edges, eval = `-log(remaining moves for opponent)`; 3) fall back to Medium heuristic |
| Cruel | opening book of known trap chains |
Cost control: at depth d, branching = out-degree of visited syllables (often <50). Depth 4 is cheap; order moves by ascending opponent out-degree and cap node count.
Anti-frustration: cap the bot below always-play-the-killer, or players quit.
## 4. Vietnamese Text Normalization (the real bug source)
1. **Unicode form** — normalize to **NFC**. `ữ` can be one codepoint or `ư` + combining tilde. Mismatch = false rejections.
2. **Tone placement variants** — `hoà`/`hòa`, `thuý`/`thúy`, `quí`/`quý`. Old-style vs new-style placement are *different codepoints*. Build an alias map or a tone-position canonicalizer, else valid words get rejected.
3. **Case & whitespace** — lowercase, collapse multiple/NBSP spaces.
4. **Syllable split** — Vietnamese syllables are space-delimited; `split(/\s+/)` is correct. Do NOT use a word-segmenter here.
5. **Used-set keying** — key on the normalized full word.
6. **Encoding** — Viet74K ships Unicode *and* TCVN3/ABC variants; take the Unicode one.
## 5. Dictionary Sources (ranked)
| # | Source | Content | Format | License | Verdict |
|---|---|---|---|---|---|
| 1 | [minhqnd/Noi-Tu-Discord](https://github.com/minhqnd/Noi-Tu-Discord) `src/assets/wordPairs.json` + `customWords.json` | ~60k 2-syllable pairs, purpose-built for nối từ | JSON `{"từ_đầu": ["từ_cuối", ...]}` | MIT | **Start here.** Already the exact index shape; MIT-safe to vendor |
| 2 | [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) — [Viet74K.txt](https://vietnamese-wordlist.duyet.net/Viet74K.txt) | 74k words, all lengths, dictionary-sorted | plain txt, Unicode + TCVN3 | unclear/aggregated | Expansion set; filter `split(' ').length===2` |
| 3 | [undertheseanlp/dictionary](https://github.com/undertheseanlp/dictionary) | consolidated VN dictionary from the underthesea NLP group | JSON/txt | check repo | Good for definitions / POS filtering (noun+adj only) |
| 4 | [viet-yomitan](https://github.com/onlyduyy/viet-yomitan) | Từ Điển Tiếng Việt Thông Dụng, 42,012 entries | Yomitan dict (JSON in zip) | check | High-quality curated monolingual entries |
| 5 | [Trannosaur/published_dicts](https://github.com/Trannosaur/published_dicts) | vi.wiktionary + en.wiktionary derived, Jan 2026 | JSON | CC BY-SA (Wiktionary) | Attribution required; largest coverage |
| 6 | [vntk/dictionary](https://github.com/vntk/dictionary) | Node package, lookup + examples | npm | check | Runtime lookup, not bulk list |
| 7 | [NNBnh/noi-tu](https://github.com/NNBnh/noi-tu), [lvdat/bot-noi-tu](https://github.com/lvdat/bot-noi-tu) | reference implementations + wordlists | — | check | Cross-check coverage / borrow trap lists |
| 8 | [titoBouzout/Dictionaries](https://github.com/titoBouzout/Dictionaries/blob/master/Vietnamese_vi_VN.txt) | spellcheck syllable list | txt | — | Syllable validation only, not compounds |
**Recommended pipeline**: vendor #1 as base → union with 2-syllable filter of #2 → optionally POS-filter with #3 → dedupe after NFC normalization → emit `words.json` (Set) and `word-index.json` (adjacency). Keep the build script in-repo so the dataset is reproducible.
**Licensing**: MIT (#1) is safe to redistribute with attribution. Wiktionary-derived (#5) is CC BY-SA — attribute and isolate in a clearly-marked file if used.
## 6. Implementation Recommendations
### Suggested layout (stack-agnostic; repo is empty so nothing is imposed yet)
```
data/
raw/ # downloaded sources
words.json # normalized Set of valid 2-syllable words
word-index.json # { firstSyllable: [word, ...] }
scripts/
build-dictionary.mjs # raw -> normalized artifacts, reproducible
src/
normalize.js # NFC, tone-variant canonicalization, split
dictionary.js # load, has(), movesFrom()
game-engine.js # state, applyMove, validate, win/lose
bot.js # difficulty strategies
```
### Minimal engine sketch
```js
export function createGame({ index, words, startWord }) {
const used = new Set([startWord]);
let current = startWord.split(' ')[1];
return {
play(raw) {
const w = normalize(raw);
const s = w.split(' ');
if (s.length !== 2) return { ok: false, reason: 'NOT_TWO_SYLLABLES' };
if (s[0] !== current) return { ok: false, reason: 'WRONG_LINK' };
if (!words.has(w)) return { ok: false, reason: 'NOT_IN_DICTIONARY' };
if (used.has(w)) return { ok: false, reason: 'ALREADY_USED' };
used.add(w); current = s[1];
return { ok: true, current };
},
moves: () => (index[current] ?? []).filter(w => !used.has(w)),
};
}
```
### Build order
1. `build-dictionary.mjs` + normalization — everything depends on data quality.
2. Engine + unit tests on rules (wrong link, reuse, unknown word, timeout).
3. CLI loop (1 human vs bot).
4. Bot difficulty ladder.
5. UI / multiplayer / timer / scoring if in scope.
## 7. Common Pitfalls
- **Linking on last *letter* instead of last *syllable*** — folk description says "chữ cái cuối", real play links syllables.
- **Skipping NFC** → valid words rejected; unreproducible across OS/keyboards.
- **Ignoring `hoà`/`hòa` tone-placement variants** → biggest source of "my word IS real!" complaints.
- **Dictionary full of 1- and 3+-syllable entries** → filter at build time, not runtime.
- **Bot always plays the killer syllable** → unwinnable; players leave.
- **Reloading a 60k-entry JSON per request** in a server context → load once at boot.
- **No per-session used-set** → infinite `a→b→a→b` loops.
- **Client-side-only validation** in multiplayer → cheatable; validate server-side.
## References
- Rules: [luatchoi.edu.vn/noi-tu](https://www.luatchoi.edu.vn/noi-tu) · [gamevui.vn](https://gamevui.vn/noi-tu-tieng-viet/game) · [hoanghamobile roundup](https://hoanghamobile.com/tin-tuc/noi-tu-online/)
- Live games: [vuanoitu.fun](https://vuanoitu.fun/) · [wordfight.online](https://wordfight.online/) · [App Store: Nối từ tiếng Việt](https://apps.apple.com/vn/app/n%E1%BB%91i-t%E1%BB%AB-ti%E1%BA%BFng-vi%E1%BB%87t/id6449588406?l=vi)
- Implementations: [minhqnd/Noi-Tu-Discord](https://github.com/minhqnd/Noi-Tu-Discord) · [NNBnh/noi-tu](https://github.com/NNBnh/noi-tu) · [lvdat/bot-noi-tu](https://github.com/lvdat/bot-noi-tu)
- Data: [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) · [Viet74K.txt](https://vietnamese-wordlist.duyet.net/Viet74K.txt) · [undertheseanlp/dictionary](https://github.com/undertheseanlp/dictionary) · [viet-yomitan](https://github.com/onlyduyy/viet-yomitan) · [Trannosaur/published_dicts](https://github.com/Trannosaur/published_dicts) · [vntk/dictionary](https://github.com/vntk/dictionary) · [titoBouzout/Dictionaries](https://github.com/titoBouzout/Dictionaries/blob/master/Vietnamese_vi_VN.txt)
- NLP resources: [vndee/awsome-vietnamese-nlp](https://github.com/vndee/awsome-vietnamese-nlp)
## Next Steps
1. Decide stack + target (CLI, web, Discord bot, mobile) — nothing in repo constrains this yet.
2. Vendor `wordPairs.json` from Noi-Tu-Discord (MIT) into `data/raw/`, write `build-dictionary.mjs`, verify entry count and 2-syllable purity.
3. Implement `normalize.js` with NFC + tone-placement canonicalization; unit-test `hoà/hòa`, `thuý/thúy`, `quí/quý`.
4. Implement engine + rule tests, then a CLI loop before any UI.
5. Add bot ladder once the engine is green.
## Open Questions
1. Target platform: CLI, web app, Discord bot, or mobile?
2. Strict 2-syllable only, or allow ≥2-syllable words?
3. Single-player vs bot, or real-time multiplayer? (multiplayer needs a server + authoritative validation — big architecture delta)
4. Are word definitions in scope? (pushes toward source #3/#4/#5)
5. Should max-difficulty bot be beatable, or is "cruel mode" wanted?
6. Vietnamese-only UI, or bilingual?