tiennm99 cbc74c7813 fix(exit): distinguish self vs peer ClientExitResponse
Fixes the server log "gameover player N: unexpected Request_CreateRoom".

Root cause: when ANY player leaves a room, leaveRoom broadcasts
ClientExitResponse to ALL players in room.Players (so peers see who
left). The broadcast fired CLIENT_EXIT on both the exiting client AND
its peer. But the client's handlers treated every CLIENT_EXIT as "I
exited" — transitioning to lobby, clearing gameState.roomId, hiding the
HUD. Meanwhile the peer's server-side state machine was still in
gameoverState. The peer then clicked Create Room from the (now wrongly
shown) lobby, and the request arrived at gameoverState's default case.

Client:
- New services/client-exit-helpers.js with isSelfExit(data, clientId).
  exitClientId === 0 or missing → bare self-ack (home.ClientExit /
  watching.ClientExit / room-closed eject). exitClientId === clientId
  → our own leaveRoom broadcast loopback. Anything else → a peer left;
  stay in state.
- game-state-service CLIENT_EXIT handler: only reset() when isSelfExit.
- game-scene._onClientExit: only transition to MenuScene when isSelfExit;
  peer exits render a toast "<nickname> left the room" instead.
- menu-ui.js module CLIENT_EXIT handler: only showLobby() when isSelfExit.

Server: the client fix alone leaves a new hole — after a peer leaves a
finished PVP room, the remaining player sits in gameoverState with a
game-over modal and two buttons. "Play Again" would try to restart a
PVP game with only one human; "Leave" still works. To close this hole
cleanly:
- state/waiting.go: add kickStaleRoomPeers helper. After a leaveRoom
  in gameoverState, if the room is a PVP room with < 2 remaining human
  players it's unplayable — push a synthetic Request_ClientExit onto
  each remaining peer's CmdCh. Their gameoverState goroutine wakes up,
  processes the synthetic exit, and returns StateHome in lockstep.
- state/gameover.go: call kickStaleRoomPeers after leaveRoom in the
  ClientExit case.

Tests:
- Rename TestFlow_CreateRoomAfterOpponentForfeit →
  TestFlow_OpponentExit_KicksPeerFromGameover and rewrite to assert
  the auto-kick behavior: black leaves → white's gameoverState must
  return StateHome WITHOUT any test-driven CmdCh push. Then both
  players can create fresh rooms.

Full suite + client build green.
2026-04-11 19:39:24 +07:00
2025-06-22 22:59:08 +07:00

Gomoku (Five-in-a-Row)

Multiplayer online Gomoku with PVP, PVE (3 AI difficulties), and spectator mode. Go server + Phaser 3 browser client communicating over protobuf WebSocket binary frames.

Forked from ratel-online by ainilili and contributors. Card games removed; gomoku rewritten with a caro-compatible protobuf protocol and Phaser 3 client (feature set ported from tiennm99/caro).


Features

  • PVP — two human players in a room
  • PVE — three AI difficulties:
    • Easy — random legal move
    • Medium — heuristic threat scoring
    • Hard — minimax depth 3 with alpha-beta pruning
  • Spectator mode — watch any in-progress game in real time
  • Owner-controlled start — room owner starts when ready; other players can join before start
  • Live lobby — room list updates as rooms open / close
  • Heartbeat / reconnect — client pings every 5 s; missed pings close the connection cleanly

Quick Start

docker compose up -d

Tear down:

docker compose down

Local Development

Prerequisites: Go 1.23+, Node 22+

# Terminal 1 — Go server
go -C server run . -p 1999

# Terminal 2 — Vite dev server
npm --prefix client install
npm --prefix client run dev

Open http://localhost:5173 in two browser tabs to play against yourself.


Architecture

Browser tab A ─────────────────────────────────────────────────┐
Browser tab B ──► http://localhost:8080  (nginx / client:8080) │
                         │                                      │
                   Phaser 3 + protobufjs                        │
                         │ WS binary frames                     │
                         ▼                                      │
              ws://localhost:1999/gomoku  (Go server)           │
                         │                                      │
              ┌──────────┴──────────┐                           │
              │  state machine      │  one goroutine per player │
              │  per-player         │  lobby → waiting → game   │
              ├─────────────────────┤                           │
              │  in-memory domain   │  rooms, players (no DB)   │
              ├─────────────────────┤                           │
              │  game engine        │  15×15 board, win detect  │
              └─────────────────────┘                           │
                         │                                      │
              common/proto/*.proto  (single source of truth)────┘

Key design decisions:

  • Server-authoritative — all move validation and win detection server-side; browser never modifies board locally
  • Protobuf binary frames over WebSocket (gorilla/websocket); common/proto/ is the single source of truth
  • State machine per playerlobby → waiting → game; states registered in server/state/
  • In-memory store — rooms and players live in hashmaps; restart loses all state (intentional — games are short-lived)
  • Channel-based turn sync — game turns flow via chan per player, no polling

Project Structure

gomoku/
├── server/           Go game server (WebSocket :1999, WS endpoint /gomoku)
│   ├── main.go
│   ├── consts/       state IDs, game constants
│   ├── database/     in-memory room + player store
│   ├── game/         gomoku engine (board, win detection, AI)
│   ├── network/      WebSocket server, connection handler
│   ├── protocol/     protobuf encode/decode helpers
│   ├── state/        state machine (lobby, waiting, game)
│   ├── Dockerfile    multi-stage Go build → distroless runtime
│   └── Makefile      build / test / docker targets
│
├── client/           Phaser 3 browser client (Vite, served by nginx :8080)
│   ├── src/
│   │   ├── scenes/       Phaser scenes (lobby, game, spectate)
│   │   ├── services/     connection, room, game, AI services
│   │   └── generated/    protobuf JS stubs (committed, no build-time protoc)
│   ├── Dockerfile    Vite build → nginx:1.27-alpine
│   └── nginx.conf    SPA fallback, gzip, aggressive asset caching
│
├── common/
│   └── proto/        request.proto / response.proto (shared by server + client)
│
├── docker-compose.yml   two services: server :1999, client :8080
└── docs/                architecture, deployment, code standards

Game Rules

  • Board: 15 × 15 intersections
  • Black moves first
  • First player to place 5 stones in a row (horizontal, vertical, or diagonal) wins
  • Full board with no winner is a draw

Protocol

  • Transport: WebSocket binary frames (ws://<host>:1999/gomoku)
  • Encoding: protobuf (see common/proto/request.proto, common/proto/response.proto)
  • Auth: client sends AuthRequest{name} immediately after connect; server replies with AuthResponse{playerId, color}
  • Heartbeat: client → HeartbeatRequest every 5 s; server replies HeartbeatResponse; 3 missed → disconnect
  • Auto-reconnect: client retries with exponential back-off on connection loss

Deployment Notes

See docs/deployment-guide.md for:

  • Docker Compose quick start
  • Building individual images
  • Reverse proxy / TLS setup (Caddy, Traefik, nginx)

Credits

Implementation history: plans/260411-1101-port-caro-feature-set/plan.md


License

Apache 2.0 — see LICENSE.

Languages
JavaScript 73.4%
Go 25.3%
HTML 1%
Makefile 0.2%
Dockerfile 0.1%