Type GameState for real instead of returning any from initialState(), which
made every game.state.* read in every component unchecked. apply() now
switches on payload.case so the oneof narrows, and is wrapped in try/catch so
a throw partway through a case cannot leave a half-mutated snapshot on
screen. Also key chain meanings by position instead of gloss, since the
dictionary gives no gloss-uniqueness guarantee, and gate the word field's
turn-seed effect on the same connection check `enabled` already uses so a
reconnect cannot seed a field the player cannot submit from.
Chat lines are keyed by a client ordinal rather than author plus
timestamp, which one seat sending a burst could duplicate within a
millisecond and turn into a Svelte runtime error in every tab. The nginx
snippet now sets X-Forwarded-For, without which naming that proxy as
trusted would let each client pick its own limiter key. Quick-match skips
a waiter whose connection has already ended instead of seating a ghost,
and a match the server could not open clears the waiting panel. A
near-miss suggestion is withheld when the word would not link or is
already used, so taking it cannot earn a second refusal. The builder's
delete-then-rename fallback is confined to Windows, where the rename over
an existing file fails; elsewhere a failed rename is reported, not made
worse by deleting the good database.
expvar counters for connections, rooms, games, submissions by rejection
reason, eliminations, chat, joins and bot moves, served on a separate
debug address so they never sit on the public mux, plus one structured
word_rejected log line per refused word carrying the normalized word and
its link. GET /readyz flips to 503 while draining; SIGTERM stops new
rooms, waits up to NOITU_DRAIN_TIMEOUT for live games, then shuts down.
GET /version and the startup log carry the build's git describe.
Fuzz targets for the frame decoder, the text sanitizer and Vietnamese
normalization; the last one found that composing before lowercasing
could leave a non-NFC result, now recomposed after lowering.
CI runs on dev as well as main, gates gofmt and golangci-lint, tracks the
buf major instead of an exact pin, and dependabot watches every
ecosystem. The lint findings that had been hidden by the default
per-issue cap are fixed.
A /rules page says, in one place, what nothing in the app said before:
the chain rule, the clock, what a dead end costs, elimination and the last
player standing, how a word is scored and the reconnect window. Linked
from the landing page and from the board and lobby headers.
The chat control moves above the chain as a pill with the unread count,
where it can be reached on a phone mid-game, and the lobby's folded chat
now carries an unread badge too.
ESLint with the Svelte and JSDoc plugins, run in CI; the real findings
it turned up (missing each keys, untyped timer handles) are fixed.
Three read-only reviews, one per surface: the solo turn loop, the online
room, and the global layout with its tokens and accessibility.
They are stateful records of what was found on 2026-09-10, not evergreen
authority — the contrast tables and the drift inventory describe the
palette as it was measured, before the fixes that follow.
Measured against the shipped corpus rather than estimated: viwiktionary is
already a third of what we ship, and undertheseanlp is bigger and denser but
carries an unlicensed Vietlex derivative in one of its three branches.
The board, the home screen and the game-over panel, in Vietnamese, served by
the Go binary as a static single-page app.
The store is a reducer over ServerMessage and computes nothing. Validity, turn
order, scores and the result are read from the wire, which is what lets one
screen serve the bot now and online play later. Every Vietnamese string lives in
one module, including the map from RejectReason to a message, so the server can
send UI keys instead of prose.
The word field is uncontrolled. A Telex or VNI input method composes a diacritic
across several keystrokes, and writing the value back on each one cancels the
composition. It is read on submit and cleared only there.
The countdown is drawn against the server's clock, estimated from the ping round
trip, and settles 300ms early so the ring never claims more time than the server
allows.
The screen owns the socket and the game while it is mounted, and gives the
current game up on the way out. Asking for a game is stored intent rather than a
condition inferred from the board being empty: clearing the board for a rematch
is that same condition, so the inference sent a second StartBotGame and the
server built two rooms that then destroyed each other.
A test greps the built bundle for dictionary words and holds a size budget, so
the wordlist cannot reach the browser unnoticed. Another reads the error codes
out of the Go transport and fails when one has no Vietnamese message.
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.
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.