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.
9.6 KiB
title, status, phase, priority, effort, dependencies
| title | status | phase | priority | effort | dependencies | ||
|---|---|---|---|---|---|---|---|
| Phase 7: Online 1v1 and Release | todo | 7 | P1 | 4d |
|
Phase 7: Online 1v1 and Release
Overview
Complete the online 1v1 experience end to end in the browser — room codes, waiting room, opponent presence, reconnect UX, rematch — then verify the whole game with browser E2E tests and ship it: CI, container image, deployment, and documentation.
Requirements
Functional
- Create a room → shareable 6-character code and a copyable invite link
- Join by code, or by opening an invite link with the code prefilled
- Waiting room until the opponent arrives; leaving cleans the room up
- Both players' nicknames shown in the waiting room and scoreboard (server-sanitized values only)
- Live opponent state: their turn, their timer, their disconnect and reconnect
- Resign, and rematch in the same room after a game ends
- Clear Vietnamese error states: room not found, room full, game already started
Non-functional
- E2E coverage of both modes with two real browser contexts
- CI: Go tests + race, JS tests,
buf lint/breaking, generated-code drift check, builds - Deployable artifact: container image with the binary, the static frontend, and
noitu.db - README documents setup, the license split, and deployment
Architecture
No new server concepts — phase 5 already implements rooms, codes, reconnect, and resign. This phase is the frontend surface for them plus release engineering.
Invite link: https://<host>/online?code=ABC123. The online route reads the query
param, prefills, and auto-joins when the code is well-formed. Room codes use an unambiguous
alphabet (no 0/O/1/I) and are displayed grouped for reading aloud.
Online screen states
idle ──create──► waiting (code shown, copy button)
│ └──opponent joins──► playing
└──join(code)──► playing | error(room_not_found | room_full | already_started)
playing ──opponent disconnects──► banner "Đối thủ mất kết nối… (Ns)"
├─ they return ──► resume playing
└─ grace expires ──► GameOver(END_OPPONENT_LEFT)
Rematch: after GameOver in a PvP room, either player may request a rematch; the room
resets its engine with a fresh opening word when both accept, or closes on decline/timeout.
Requires a small additive protocol change — RequestRematch / RematchState — which is why
it lands here rather than in phase 4: additive tags only, per the phase-4 compatibility rule.
Deployment — Docker image on a VPS behind a reverse proxy (validated decision):
Dockerfile (multi-stage)
node → npm ci && npm run build → /web/build
go → CGO_ENABLED=0 go build ./cmd/... → /noitu-server
data → curl the 179 MB dictionary.db → build-dictionary → /data/noitu.db
final → distroless/static + binary + /web + /data/noitu.db + /data/LICENSE + ATTRIBUTION
CGO_ENABLED=0 works because the SQLite driver is modernc.org/sqlite (pure Go) — the
payoff for that phase-1 decision. The 179 MB upstream download happens only in the data
builder stage, so it never reaches the final image; noitu.db is copied in as its own
layer, keeping the CC BY-SA 4.0 artifact physically distinct from the Apache-2.0 binary.
CI data strategy (consequence of not republishing a derived DB): CI must never download 179 MB per run.
| Job | Data used |
|---|---|
| Unit tests (every push/PR) | Small fixture DB built in-test from a checked-in word sample (phase 2 helper) |
| E2E tests (every push/PR) | Same fixture DB, ~200 curated words — enough to script deterministic games |
| Full dictionary build (manual / nightly) | Real upstream, with the download cached by release tag |
| Docker image build (release only) | Real upstream, inside the builder stage |
Related Code Files
- Modify:
proto/noitu/v1/game.proto— addRequestRematch,RematchState(new tags only) - Modify:
server/gen/...,web/src/lib/proto/...— regenerate - Modify:
server/internal/wsapi/room.go— rematch handling, room reset - Modify:
web/src/routes/online/+page.svelte— replace the phase-6 stub with the full create/join/waiting/error flow - Create:
web/src/lib/components/RoomCodePanel.svelte,WaitingRoom.svelte,OpponentStatus.svelte,RematchPrompt.svelte - Modify:
web/src/lib/stores/game.svelte.js— room/opponent/rematch state - Modify:
web/src/lib/i18n/vi.js— online-mode strings - Create:
e2e/bot-game.spec.js,e2e/pvp-game.spec.js,e2e/reconnect.spec.js(Playwright) - Create:
playwright.config.js - Create:
Dockerfile,.dockerignore - Create:
.github/workflows/ci.yml - Modify:
README.md— quickstart, architecture, deployment, license split - Create:
docs/deployment.md
Implementation Steps
- Add
RequestRematch/RematchStateto the proto with new tags; regenerate both targets; extend the round-trip tests. - Server: rematch in
room.go— both-accept resets the engine with a new opening word and re-emitsGameStarted; decline or timeout closes the room. Cover with a room test. online/+page.svelte: the state machine above, code input (uppercase, alphabet-restricted, paste-friendly),?code=prefill and auto-join.RoomCodePanel: large grouped code, copy button, copy invite link, Web Share on mobile where available.WaitingRoom: opponent-pending state with a cancel that leaves the room cleanly.OpponentStatus: turn indicator, disconnect banner with the grace countdown, reconnect confirmation.RematchPrompton the game-over panel for PvP; bot mode keeps a plain "chơi lại".- Vietnamese strings for every new state, including the three join errors.
- Playwright setup: build the frontend, run the real server against the ~200-word fixture DB (never the real dictionary — CI must not download 179 MB), run specs against it.
- E2E
bot-game.spec.js: pick Hard, play scripted valid and invalid words, assert rejection messages, play to game over, assert the score and personal-best marker. - E2E
pvp-game.spec.js: two browser contexts with different nicknames, create + join by code, assert each side shows the other's sanitized nickname, alternate turns, assert each side sees the other's word, resign ends it correctly. - E2E
reconnect.spec.js: drop one context's socket mid-game, assert the opponent's disconnect banner, restore within grace, assert the board matches on both sides. Dockerfilemulti-stage as above; verify the image runs with onlyNOITU_DB_PATHset.ci.yml:go vet,go test ./... -race,npm test,npm run build,buf lint,buf breaking, generated-code drift check, Playwright — all on the fixture DB. Separate release-only job builds the Docker image. Add an assertion that the built image containsdata/LICENSEanddata/ATTRIBUTION.md.README.md: what the game is, quickstart (make fetch-dictdownloads 179 MB once, thenmake dict),maketargets, architecture diagram, License section (Apache-2.0 code / CC BY-SA 4.0 data with attribution), and a link todata/ATTRIBUTION.md.docs/deployment.md: env vars (incl.NOITU_TURN_LIMIT=20s,NOITU_ALLOWED_ORIGINS), TLS/wss://behind a reverse proxy (Upgrade/Connectionheaders, proxy read timeout longer than the WS keepalive, buffering disabled), health check, and hownoitu.dbreaches the container.
Success Criteria
- Two people on different machines play a full game via a shared room code
- Invite link opens straight into the room
- All three join errors show correct Vietnamese messages
- Disconnect shows the opponent a grace countdown; return inside it resumes with identical boards; expiry awards the win
- Rematch restarts in the same room with a new opening word
- Resign ends the game immediately with the right winner
- Playwright suite green: bot game, PvP game, reconnect
- CI green on all steps including the generated-code drift check, without ever downloading the 179 MB upstream DB
docker runwithNOITU_DB_PATHserves a playable game; the image containsdata/LICENSEanddata/ATTRIBUTION.mdbut not the 179 MB upstream file- README license section and in-app attribution agree with
data/ATTRIBUTION.md - Full pass over
plan.mdsuccess criteria — every box checkable
Risk Assessment
| Risk | Signal | Response |
|---|---|---|
| Reverse proxy breaks WS upgrade in production | Works locally, 400/502 on deploy | docs/deployment.md states the required proxy headers and timeouts; the health check plus a post-deploy WS smoke test catch it before users do |
| Playwright PvP tests flake on timing | Intermittent CI red | Drive by deterministic server events (wait for the turn indicator), never fixed sleeps; run the suite with a long turn limit via env |
| Rematch protocol addition breaks phase-6 clients | Decode errors in an already-running client | Additive tags only; buf breaking in CI is the guard, and protocol_version lets the server reject a stale client cleanly |
| Room codes guessable enough to join a stranger's game | Reports of uninvited joins | crypto/rand codes plus per-IP join rate limiting from phase 5; 32^6 space with rate limiting makes scanning impractical |
| CC BY-SA obligations lost during packaging | Image ships without data/LICENSE |
The Docker copy step includes the whole data/ directory, and a CI assertion checks data/LICENSE and data/ATTRIBUTION.md exist in the built image |
| Scope creep into accounts/leaderboards at the finish line | New requests during release work | Explicit non-goals in plan.md; log them as post-v1 items instead |