# 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 Playable: vs bot at three difficulties, and online 1v1 by room code. See [`plans/260904-1125-noi-tu-web-game/plan.md`](./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`](https://github.com/coder/websocket) | | Dictionary | SQLite via `modernc.org/sqlite` (CGo-free), read-only at runtime | ### The wire contract [`proto/noitu/v1/game.proto`](./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. ### Online play A player creates a room and gets a six-character code and an invite link. The alphabet omits `0`/`O` and `1`/`I`/`L`, because these codes get read aloud. The other player types the code or opens the link, which joins on arrival. Both players see the other's server-sanitized nickname, never the raw input. A disconnect holds the seat for a grace window and shows the opponent a countdown; a return inside it resumes the same position, rebuilt from the engine rather than from a recorded stream. When a game ends, either player may ask for a rematch and the room restarts with a new opening word once both agree. Leaving is how a rematch is declined — there is no separate message for it. ### 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`](https://buf.build/docs/installation) is needed only to change the WebSocket schema — the generated code is committed, so building and running the project does not require it. ```sh 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`](./data/ATTRIBUTION.md). ## Running the server ```sh 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 ```sh 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 | | `fixture-dict` | Build the small test dictionary, no download needed | | `test` | Run Go and JavaScript tests | | `test-e2e` | Run the Playwright suite against the fixture dictionary | | `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: ```sh # 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 # fixture-dict cd server && go run ./cmd/build-dictionary --words ../testdata/fixture-words.txt --out ../data/fixture.db --min-words 150 # test-e2e (needs the fixture dictionary above) cd web && npm run test:e2e # proto (only when proto/noitu/v1/game.proto changes) cd web && npm ci buf generate && buf lint ``` ## Testing | Suite | What it covers | |---|---| | `cd server && go test ./... -race` | The rules engine, the bot, the dictionary, and the whole transport layer | | `cd web && npm test` | The store, the socket client, the Vietnamese copy, and the built bundle | | `cd web && npm run test:e2e` | Real browsers against the real binary: a bot game, an online game across two browser contexts, and reconnect | The end-to-end suite plays against a small dictionary derived from [`testdata/fixture-words.txt`](./testdata/fixture-words.txt) through the same builder the real one uses, so CI never downloads the 179 MB upstream release. ## Deployment One container image carries the binary, the built frontend and the derived dictionary. See [`docs/deployment.md`](./docs/deployment.md) for configuration, reverse-proxy requirements, and what a restart costs. ```sh docker build -t noitu:latest . docker run -p 8080:8080 noitu:latest ``` ## License This project is distributed under **two licenses**, applying to different artifacts. See [`NOTICE`](./NOTICE) for the full statement. | Artifact | License | |---|---| | All source code (`server/`, `web/`, `proto/`) | [Apache-2.0](./LICENSE) | | Dictionary data (`data/noitu.db`) | [CC BY-SA 4.0](./data/LICENSE) | The dictionary is derived from [minhqnd/dictionary](https://github.com/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`](./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.