tiennm99 e5b03d041d fix(wsapi): set cache headers on the served frontend
Assets under _app/immutable carry a content hash in the name, so a changed file
is a changed URL and the old one can be cached forever. The shell cannot: it
names those hashed assets, so a copy cached across a deploy points at files that
no longer exist and the app loads into a blank page with nothing in the log.
2026-09-05 12:55:09 +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%