PlayedWord.player_id was declared and read by the client but never set by
the server, so a room of three or four never showed who played each word.
The chain byline now comes from the room, with a producer-side test.
The process also gains the ceilings it was missing: a cap on live rooms and
on open sockets, a per-connection frame-rate limit so a payload-less frame
is no longer free, and an opt-in trusted-proxy list so the join limiter can
tell players apart behind the documented reverse proxy instead of putting
them in one bucket. The typed word is sanitized before the engine stores it,
since every seat is shown it; a room exiting on its idle clock releases the
sessions still bound to it; the dictionary builder escapes its SQLite path
like the store does and renames over the old database instead of deleting
it first.
reader for both wikitext dialects; `meanings(word, ord, pos, gloss)` table; `meaning_count`/`words_with_meaning`/`source_pages` in meta, `source_rows` gone, builder_version 5; `--dump`/`--min-pages` replace `--kaikki`; attribution names the dump and the definition excerpts; 36,200 words, 96.9% with a meaning, every kaikki word kept.
Drop what nothing uses any more: the --max-syllables flag with its
sourceSpec field, meta row and reject reason; the store's clearRejection;
the --gap CSS token; three orphaned i18n strings; two exports with no
importer; the preview npm script; the proto.yml "baseline exists" step
that has been unconditionally true since the schema landed on main; and
two root-level .gitignore entries for paths Playwright never writes.
Correct text that outlived its subject: NOTICE named a tools/ directory
that never existed, the README still pointed at the first plan and
carried a migration note for retired sources, and a few comments still
said "release" for an upstream that is a weekly export. The pin test
header now says three copies and also holds README and ATTRIBUTION to
the same URL; the Make-targets table lists clean and help.
Wire fixtures regenerated so client_hello uses the ProtocolVersion
constant and server_game_started carries the 30s turn limit.
builder_version becomes 4 for the dropped meta row.
Replace the pinned 2018 undertheseanlp wordlist with kaikki.org's current
wiktextract export of the Vietnamese Wiktionary, read with --kaikki. The
same authors and website, eight years fresher: 34,813 words instead of
26,845, with every graph metric up and bot game length unchanged.
The export is fetched fresh for every build and is not pinned, by the
owner's decision: kaikki keeps no dated snapshots, so a checksum would
break weekly. The builder therefore hashes the file as it streams it and
records source_sha256, source_rows and source_fetched_at in meta; the
fetch downloads to a .part name and renames on success; a truncated or
non-JSON body fails the build, and the --min-words floor rises to 30,000.
DICT_SHA256 and verify-dict are gone; the Makefile, Dockerfile and the
builder's URL constant are held in agreement by a test.
Current Wiktionary text is CC BY-SA 4.0, so the data licence returns to
4.0: data/LICENSE is restored, and NOTICE, ATTRIBUTION, README, the image
docs and the in-game footer credit Wiktionary tiếng Việt's contributors
and wiktextract/kaikki.org. builder_version becomes 3 for the changed
meta contract.
Replace the 179 MB minhqnd SQLite aggregate with the 4.8 MB
undertheseanlp/dictionary JSONL, pinned by commit and SHA-256, reading
only rows tagged "wiktionary". The two other wordlists in that file are
never read: hongocduc is GPL and would force a relicense, tudientv is an
unlicensed derivative of a commercial dictionary.
build-dictionary gains --merged and --sources (names validated, default
wiktionary), a shared finish() tail, and meta rows for the source commit
and the sources kept and excluded. The SQLite --in path, its schema
auto-detection and their tests are removed. Fixture builds now record
that they carry no upstream data instead of inheriting a licence string.
The --min-words floor moves from 40,000 to 20,000; the corpus is 26,845
words, down from 48,216, all of the loss being words absent from the
2018 Wiktionary scrape. Capitalization is not a filter.
The data licence follows the source text: CC BY-SA 3.0 Unported, which
is what vi.wiktionary.org carried in 2018. LICENSE, NOTICE, ATTRIBUTION,
the README, the image docs, the builder's meta string and the in-game
footer all name Wiktionary tiếng Việt's contributors as the authors and
undertheseanlp as the intermediary. The Makefile/Dockerfile pin test now
also checks the commit the builder stamps into the database.
A room screen wide enough for it is two columns now: the game or the
lobby on one side and the room's conversation on the other, so neither
has to be scrolled past to reach the other. A phone keeps one column,
the game first, with the chat folded behind an unread count while a game
is on. One chat panel spans both phases by staying in the same place in
the markup — a remounted panel reopened having read nothing, and handed
the reader the lobby's conversation back as unread mail.
The room also keeps the score of the series. PlayerSlot carries the
games a seat has won since the room opened, credited before the lobby is
broadcast so the players see it the moment a game ends, and shown in the
lobby and beside each player's score on the board. It belongs to the
seat rather than to the room, because vacating one is exactly when the
name on it stops meaning the same person.
The conversation reads as a log — one line per message, "name: text",
each seat in its own colour — rather than as a stack of bubbles, which
stops being legible once four people are talking. ChatMessage carries
the seat that spoke, so a line is coloured by what the server said
rather than by matching display names, and a line whose seat has been
vacated is in nobody's colour.
The board's word button gets a test id of its own: it and the chat's
send button both read "Gửi", and the two are now on screen together.
A room holds up to four people and needs two to start. Both numbers are
server constants sent to the client in RoomState, so the lobby draws
whatever the server allows and widening a room is a server change alone.
Failing a turn eliminates that player rather than ending the game. The
syllable and the used words survive them, the turn passes to whoever is
next, and the last player standing wins. Two seats is that same rule seen
from close up, which is why there is one implementation of it and not two.
A dead end still costs the first player to face it their own clock, as
before: they get their turn, and lose it. Everyone behind them has already
seen that board, so they go out together rather than each sitting out a turn
limit they cannot use — which leaves the player who closed the position
standing, the same outcome two players get.
A player who is knocked out keeps their seat. They watch the rest of the
game, chat included, with only the word input gone, and everybody lands back
in the same lobby when it ends. The result screen is the whole table, ranked
by who outlasted whom, with each score reported beside the place rather than
deciding it.
The turn clock is deliberately not paused for a seat that has dropped, so a
player who loses their connection on their own turn loses it the way anybody
else would. Their reconnect window decides only whether they are still in the
game afterwards. Any number of windows can be open at once, settled by one
timer armed for the nearest of them.
Starting waits for every guest, not merely the first: a room of four that
began on one yes would have dealt three people a turn they never agreed to.
Kicking names a seat and is still refused on a player who is ready, and on
the owner's own — leaving is what an owner who wants out does, and it hands
the room on. Joining stays a lobby thing: a room with a game running turns a
latecomer away even with seats going spare, because there is no way to hand
somebody a game already in progress.
BREAKING CHANGE: RoomState, TurnUpdate and GameOver lose the fields that
could only ever describe a second player, OpponentLeft is retired in favour
of presence on RoomState, and suggestions move to the new PlayerEliminated —
they describe the position that beat a player, which by the end of a longer
game is nobody else's position. ProtocolVersion goes to 2, so a client built
against 1 is refused with a readable error rather than decoding a frame that
now means something else.
Chat belongs to the room rather than to a game, so it works in the lobby
while they agree on one, during it, and in the lobby it ends in.
A player is replayed what was said while they held their seat. That is what
a refresh restores, and it is also the boundary: a room code is pasted into
group chats by design, so somebody who redeems one starts at silence rather
than reading what the last two people said. A seat records where the
conversation stood when it was filled; the room keeps twenty lines and no
more, so a room that lives all day cannot grow.
Text is untrusted input rendered in a stranger's browser, so it goes through
the filter nicknames already used — now with a cap on stacked combining
marks, which that filter admitted. Twenty runes made mark stacking a
curiosity; two hundred make it a glyph cluster tall enough to cover the
board, and it would sit in the history being replayed to everyone who
followed.
Talking is not playing. A chat message does not reset the room's idle clock,
or one open tab could hold a room and its code for the life of the process
by typing into it once every nine minutes. It does not spend the move budget
either, and a line to a player who cannot keep up is dropped rather than
allowed to close their session — losing a line is recoverable, losing a
session mid-game costs them the game. A history is not droppable that way:
it is the frame that corrects a whole panel, and there is nothing behind it.
When a seat is vacated its words stay and its author goes, name included,
and the player who stayed is re-synced rather than left holding a name that
the next person through the door could ask for.
A room used to be a wrapper around one game: joining started it, and the
room died with it unless both players accepted a rematch inside thirty
seconds. It is now a lobby that outlives its games.
Whoever created the room owns it and the other seat is the guest. The guest
readies and the owner starts; the owner has no readiness of their own,
because starting is the same statement. A finished game returns both to the
lobby, where the next one is agreed exactly as the last was — the readiness
that started a game is spent with it.
A guest takes their readiness back before leaving, which is deliberate
friction: a player the owner is waiting on should have to say so before
walking away. The owner can free the seat of a guest who is not ready, and
not of one who is — readiness is a commitment, not an inconvenience. An
owner who leaves hands the room to whoever is left, unreadied, because they
are the one who starts now.
Something has to bound a room that outlives its games: the last player out
closes it, as does ten minutes in a lobby nobody started a game in. A
dropped connection is still not a player leaving — the seat is held for the
reconnect window in the lobby as well as mid-game, so a refresh no longer
costs somebody their room, and a resume lands in the lobby it left.
The rematch handshake is retired, and RoomCreated and RoomJoined go with it.
All three described part of what RoomState now describes in full, and three
messages for one lobby is three ways for a client to hold a view of it the
server never had. One snapshot per recipient, broadcast from the one place
that knows an input is finished, so no handler can forget to send it.
Closing the position no longer wins the game on the spot. The player handed
a syllable that starts nothing keeps the turn they were given and loses it to
the clock, the way they lose any turn they cannot answer — the win used to
land before they had seen the board at all. A turn lost to a position nobody
could answer is reported as the dead end it was rather than as time spent
thinking.
The bot is the exception, and stays one: it has no clock to spend, so the
room settles its dead end the moment the search comes back empty. NoMove
does that without dressing it up as a resignation the bot never chose.
GameOver now carries a few of the words the position still had, filled for
the losing player only — the winner was not the one who was stuck. An empty
list on a loss is the other half of the message: nothing could have been
played, so the panel says so instead of listing nothing.
Three things a player asked for after playing:
The field now arrives holding the syllable the next word has to start with,
so only the half that is still in question gets typed. It is written once per
turn and never over an existing draft, which keeps the field uncontrolled and
leaves Telex and VNI composition alone.
The chain lists the newest word first. The move that decides what to play
next is the last one, and it belongs where the eye lands rather than at the
end of a list that has to be scrolled.
A finished game can be downloaded as a plain-text transcript: the chain in
playing order, who played each word, and the final score. The export keeps
playing order because a chain read on its own only makes sense from the
opening word forward — newest-first is a reading aid for the live board, not
the shape of the game.
One distroless image of about 25 MB carries the binary, the built frontend and
the derived dictionary. The 179 MB upstream release is downloaded in a builder
stage and never reaches the final image; the derived wordlist is copied in as
its own layer alongside its licence, attribution and notice, because CC BY-SA
4.0 applies wherever that data is distributed and an image is distribution.
FIXTURE_DICT=1 builds the same Dockerfile against the checked-in word sample,
so the image is built and smoke-tested on every push rather than only at
release. An image built only at release time is an image that breaks at release
time.
CI runs the Go suite under race detection, the frontend type check and tests,
the browser suite, and the image with its licence assertions. The wire contract
keeps its own workflow; the test steps it duplicated were removed from it.
docs/deployment.md covers configuration, the reverse-proxy settings that each
break the game in a way that looks like something else, and what a restart
costs.
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.
One goroutine owns each room and its engine. The room goroutine starts before
anyone is seated and seating is itself a message, so reading run() is a complete
proof of the concurrency contract rather than a convention to uphold. The bot
searches a frozen copy of the board instead of the live engine.
Reads carry no deadline; liveness is ping-based, because a read timeout cannot
distinguish a healthy player idling in the lobby from a dead socket.
The hub no longer binds a joiner to a seat before the room decides whether to
seat them. Anyone holding a room code could previously resign or play on a
seated player's behalf, and the room code is the only credential online 1v1 has.
cmd/noitu-server serves the API and, when NOITU_WEB_DIR is set, the built
frontend, with unknown paths falling back to index.html for client routes. All
configuration is environment-only and every variable has a working default.
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.
Reduce the 179 MB minhqnd/dictionary SQLite release to a ~3 MB game
wordlist: 48,216 Vietnamese words of two or more syllables, indexed by
first and last syllable with an out-degree table for dead-end detection.
Source schema is auto-detected rather than hardcoded, since it is someone
else's release artifact; explicit flags override it and are validated
against the real tables, because SQLite silently reads an unknown
double-quoted column as a string literal.
Accept a word only if every syllable fits Vietnamese phonotactics. An
alphabet check is not enough: "credit card" and "come out" use only
letters Vietnamese has, and the multilingual source tags them as
Vietnamese. Onset matching backtracks so the gi digraph does not swallow
the nucleus of common words like "gi", "gin" and "gi" (rust).
Record accepted spelling variants in an alias table rather than solving
tone placement at runtime. Tone shifting applies only to open oa/oe/uy
syllables, since "hoan" and "hoai" have a single correct spelling, and
"qu" is a consonant onset. The i/y alternation uses an onset allowlist
plus explicit pairs, because it is lexical rather than productive.
Variants are generated as a cross product over syllables so a word with
two variable syllables still offers the fully modern spelling.
Build to a temporary file and rename only after commit, so a failed run
cannot leave an empty database where a good one was, then re-open the
result and verify its invariants on disk.
Licensing: the derived data is CC BY-SA 4.0 and stays a separate artifact
from the Apache-2.0 code, loaded at runtime and never embedded. Ships
NOTICE, data/LICENSE and an attribution file recording every change.