tiennm99 b541ddf0b5 feat(proto): add rematch to the wire contract
RequestRematch asks to play the same room again; RematchState tells each
player where both answers stand, rendered per recipient so neither client has
to work out which acceptance is whose.

There is no decline message. Leaving is the decline, and the server already
learns about that from the socket closing, so one message and one timeout
cover every way a rematch does not happen.

Both tags are new, nothing existing moved, and a phase-6 client that has never
heard of rematch_state ignores it rather than failing to decode.
2026-09-05 14:17:35 +07:00
2026-09-04 10:26:56 +07:00

noitu

Trò chơi nối từ tiếng Việt trên web — chơi với máy hoặc đấu 1v1 trực tuyến.

A web implementation of the Vietnamese word-chain game nối từ: each player submits a meaningful word of at least 2 syllables whose first syllable matches the last syllable of the previous word. No word may be reused. Fail to answer in time and you lose.

ngôn ngữ → ngữ pháp → pháp luật → luật lệ → ...

Status

In development. See plans/260904-1125-noi-tu-web-game/plan.md for the implementation plan and phase breakdown.

Architecture

The server is authoritative: the browser never holds the wordlist, so word validation cannot be bypassed, and the bot and player-vs-player paths share one rule implementation.

SvelteKit SPA  ──WebSocket + Protobuf──►  Go server  ──►  SQLite dictionary
Component Choice
Frontend SvelteKit 2 / Svelte 5 (JavaScript), adapter-static
Transport WebSocket, Protobuf binary frames (@bufbuild/protobuf ↔ protoc-gen-go)
Backend Go, coder/websocket
Dictionary SQLite via modernc.org/sqlite (CGo-free), read-only at runtime

The wire contract

proto/noitu/v1/game.proto is the single source of truth for every message crossing the WebSocket. buf generates the Go types into server/gen/ and the JavaScript types into web/src/lib/proto/; both trees are committed, and neither side hand-writes a message type.

The Go test suite emits binary fixtures into proto/testdata/, and the JavaScript suite decodes those same bytes — so the two generated clients are checked against one artifact rather than against each other's assumptions. Regenerate the fixtures with cd server && go test ./internal/wsapi -update whenever the schema changes.

The frontend

web/ is a SvelteKit single-page app in JavaScript, built by adapter-static and served by the Go binary. It renders what the server sent and decides nothing: the store is a projection of ServerMessage, so validity, turn order and the result all come from one authority. The only client-owned state is the theme, the personal best per difficulty, and the input box.

The word field is deliberately uncontrolled. Vietnamese diacritics are composed over several keystrokes by a Telex or VNI input method, and writing the value back on every keystroke cancels that composition and mangles the accent.

Every Vietnamese string lives in web/src/lib/i18n/vi.js, including the map from RejectReason to a message. That is why ServerError.code is a UI key such as room_not_found and never prose. A test walks the generated enums and fails when a value has no message, so a schema change cannot quietly ship an untranslated screen.

The countdown is drawn against the server's clock, estimated from the Ping/Pong round trip, and settles 300ms early so the ring never claims more time than the server allows.

Setup

Requires Go 1.25+, Node 20+, and optionally make. buf is needed only to change the WebSocket schema — the generated code is committed, so building and running the project does not require it.

make fetch-dict   # one-time: downloads the ~179 MB upstream dictionary into data/
make dict         # derives data/noitu.db (the game's wordlist) from it
make test         # run all tests
make run          # build and start the server

make fetch-dict is a one-time cost per machine. Neither database file is committed; both are build artifacts. See data/ATTRIBUTION.md.

Running the server

make dict          # once, after fetch-dict
make run           # builds and starts on :8080

Configuration is environment-only; every variable has a working default.

Variable Default Meaning
NOITU_ADDR :8080 Listen address
NOITU_DB_PATH data/noitu.db Derived dictionary, loaded read-only at startup
NOITU_TURN_LIMIT 20s Turn deadline, identical for bot and PvP games
NOITU_GRACE 30s How long a disconnected player's seat is held for a reconnect
NOITU_ALLOWED_ORIGINS (unset) Comma-separated origin allowlist. Unset means same-origin only
NOITU_WEB_DIR (unset) Built frontend to serve. Unset serves the API alone

An invalid duration is logged and ignored rather than silently changing the rules of the game.

Endpoints: GET /ws (Protobuf over binary WebSocket frames), GET /healthz, and — when NOITU_WEB_DIR is set — the frontend on everything else, with unknown paths falling back to index.html because deep links are client routes.

Smoke-testing without a frontend

The whole game is playable over a raw WebSocket client. Frames are binary protobuf, so a text tool like websocat cannot compose them by hand; the practical path is a short Go client importing server/gen/noitu/v1, which is exactly what server/internal/wsapi tests do in-process.

Send Hello{protocol_version: 1, nickname: "..."} first — every other message is refused until the handshake completes — then StartBotGame and reply to each TurnUpdate with a SubmitWord carrying the turn_seq you were given.

Running the frontend in dev

make run       # the Go binary on :8080
make web-dev   # Vite on :5173, proxying /ws to :8080

The client resolves its socket from its own origin in both environments, so there is no dev-only URL to get wrong.

Make targets

Target Does
fetch-dict Download the upstream dictionary.db (~179 MB) into data/
dict Derive data/noitu.db from the upstream database
server Build the Go server binary
web Build the SvelteKit frontend to static assets
web-dev Run the frontend dev server, proxying /ws to a local server
proto Regenerate the Go and JS wire types from proto/ (needs buf)
proto-check Lint the schema and verify the committed generated code is in sync
test Run Go and JavaScript tests
run Build and run the server locally
verify-dict Re-check the downloaded dictionary against its pinned SHA-256

Without make

make is not installed everywhere (notably Windows). Every target is a thin wrapper:

# fetch-dict
curl -L -C - -o data/dictionary.db   https://github.com/minhqnd/dictionary/releases/download/v2.0.0/dictionary.db

# dict
cd server && go run ./cmd/build-dictionary --in ../data/dictionary.db --out ../data/noitu.db

# test
cd server && go vet ./... && go test ./... -race

# server
cd server && CGO_ENABLED=0 go build -o ../noitu-server ./cmd/noitu-server

# web
cd web && npm ci && npm run build

# test-web (npm test builds first, then checks the bundle carries no wordlist)
cd web && npm run check && npm test

# proto (only when proto/noitu/v1/game.proto changes)
cd web && npm ci
buf generate && buf lint

License

This project is distributed under two licenses, applying to different artifacts. See NOTICE for the full statement.

Artifact License
All source code (server/, web/, proto/) Apache-2.0
Dictionary data (data/noitu.db) CC BY-SA 4.0

The dictionary is derived from minhqnd/dictionary (data licensed CC BY-SA 4.0), which itself aggregates Wiktionary and other Vietnamese dictionary sources. CC BY-SA is a share-alike license: any redistribution of the derived database — including inside a container image — must carry the same license, the attribution, and the record of modifications recorded in data/ATTRIBUTION.md.

The database is loaded at runtime from a file and is never embedded or linked into the Go binary, keeping the two licensing regimes on separate artifacts.

S
Description
Vietnamese word-chain game (nối từ) on the web: play a bot or 2–4 players online. Go server, SvelteKit, Protobuf over WebSocket, Wiktionary dictionary.
Readme Apache-2.0
2.7 MiB
0 Stars 1 Watchers 0 Forks
Languages
Go 58.6%
JavaScript 27.6%
Svelte 12.2%
CSS 0.7%
Makefile 0.5%
Other 0.4%