A production-ready, real-time multiplayer Tic-Tac-Toe game built with a server-authoritative architecture using Nakama as the game backend and Phaser 3 as the frontend.
┌─────────────────────────────┐ WebSocket / HTTP
│ Browser (Phaser 3 client) │ ◄────────────────────────► ┌──────────────────┐
│ xoxo-phaserjs/ │ │ Nakama Server │
│ • MainMenu scene │ │ (Go plugin) │
│ • InGame scene │ │ │
│ ├── Turn timer countdown │ │ Match Handler │
│ └── Post-game leaderboard│ │ • Move validation│
└─────────────────────────────┘ │ • Win detection │
│ • Turn timers │
│ • AI integration │
│ │
│ RPCs │
│ • find_match │
│ • get_leaderboard│
│ • rewards │
└────────┬─────────┘
│
┌────────▼─────────┐
│ PostgreSQL │
│ • Match state │
│ • Player stats │
│ • Leaderboard │
└──────────────────┘
│
┌────────▼─────────┐
│ TensorFlow Serving│
│ • AI move model │
└──────────────────┘
| Concern | Decision | Reason |
|---|---|---|
| Game state authority | 100% server-side (Go match handler) | Prevents cheating; all moves validated before broadcast |
| Matchmaking | Label-based match listing + on-demand creation | Simple, scalable; no separate queue service needed |
| Concurrent games | Nakama's built-in isolated match rooms | Each match has independent state, presences, and message queues |
| Leaderboard | Nakama native leaderboard API + Storage for streaks | Atomic, consistent; no custom ranking code |
| Turn timer | Server tick-based deadline (Unix timestamp) | Client countdown is display-only; server enforces forfeit |
| AI opponent | TensorFlow Serving model | Decoupled inference; can swap model without touching game logic |
| Frontend config | window.NAKAMA_* globals set by nginx envsubst |
Zero-rebuild deployment — only the config file changes between environments |
lila/
├── nakama-project-template/ ← Nakama Go plugin (backend)
│ ├── main.go Module init, RPC & match registration
│ ├── match_handler.go Server-authoritative game loop
│ ├── match_rpc.go find_match + get_leaderboard RPCs
│ ├── leaderboard.go Stats persistence & leaderboard updates
│ ├── ai.go TensorFlow Serving AI integration
│ ├── daily_rewards.go Daily reward RPC
│ ├── session_events.go Single-device session enforcement
│ ├── api/xoxoapi.proto Protobuf message definitions
│ ├── docker-compose.yml Local dev stack
│ └── Dockerfile Plugin builder image
│
├── xoxo-phaserjs/ ← Phaser 3 frontend
│ ├── src/
│ │ ├── nakama.js Nakama JS client singleton
│ │ ├── config.js Canvas dimensions
│ │ └── scenes/
│ │ ├── MainMenu.js Mode select + matchmaking trigger
│ │ ├── InGame.js Board, timer, leaderboard display
│ │ └── Matchmaking.js Loading screen
│ ├── public/
│ │ ├── nakama-config.js Runtime config (overwritten in prod)
│ │ └── index.html
│ ├── nakama-config.js.template envsubst template for production
│ ├── nginx.conf nginx server block
│ ├── docker-entrypoint.sh Renders template → starts nginx
│ └── Dockerfile Multi-stage: node build → nginx serve
│
├── docker-compose.prod.yml ← Full production stack
├── .env.example ← Environment variable template
└── README.md
cd nakama-project-template
docker compose up --build
This starts:
54328501 (internal)7349 (socket), 7350 (HTTP/API), 7351 (gRPC)Nakama Console: http://localhost:7351
cd xoxo-phaserjs
npm install
npm run dev
Open http://localhost:5000 (or the port shown).
The default public/nakama-config.js points to localhost:7350 — no changes needed for local dev.
80, 7349, 7350, 7351 open in the firewall# 1. Clone the repository
git clone <your-repo-url> xoxo && cd xoxo
# 2. Create your .env file
cp .env.example .env
# Edit .env — set POSTGRES_PASSWORD and NAKAMA_HOST (your server's public IP/domain)
nano .env
# 3. Build & start all services
docker compose -f docker-compose.prod.yml up -d --build
# 4. Verify everything is healthy
docker compose -f docker-compose.prod.yml ps
The frontend is now served at http://<your-server-ip>/.
The Nakama API is at http://<your-server-ip>:7350/.
| Variable | Default | Description |
|---|---|---|
NAKAMA_HOST |
(required) | Public IP or domain that browsers connect to |
NAKAMA_PORT |
7350 |
Nakama HTTP/WebSocket port |
NAKAMA_USE_SSL |
false |
Set true when using a TLS reverse proxy |
NAKAMA_SERVER_KEY |
defaultkey |
Nakama server key — change in production |
POSTGRES_PASSWORD |
(required) | PostgreSQL password |
POSTGRES_DB |
nakama |
PostgreSQL database name |
POSTGRES_USER |
postgres |
PostgreSQL user |
Put Caddy in front of both services for automatic HTTPS:
your-domain.com {
reverse_proxy frontend:80
}
nakama.your-domain.com {
reverse_proxy nakama:7350
}
Then set NAKAMA_HOST=nakama.your-domain.com, NAKAMA_PORT=443, NAKAMA_USE_SSL=true.
| RPC ID | Description | Request | Response |
|---|---|---|---|
find_match |
Find or create a match | { fast: bool, ai: bool } |
{ matchIds: [string] } |
get_leaderboard |
Top-10 global leaderboard | {} |
{ records: [LeaderboardEntry] } |
rewards |
Claim daily reward | {} |
wallet update |
| OpCode | Direction | Description |
|---|---|---|
1 (START) |
Server → Client | New game round; includes board, mark assignments, first deadline |
2 (UPDATE) |
Server → Client | Board after a valid move; includes new deadline |
3 (DONE) |
Server → Client | Game over; includes winner mark and positions |
4 (MOVE) |
Client → Server | Player's chosen position (0–8) |
5 (REJECTED) |
Server → Client | Move was invalid |
6 (OPPONENT_LEFT) |
Server → Client | The other player disconnected |
7 (INVITE_AI) |
Client → Server | Replace disconnected opponent with AI |
deadline timestamp with every START and UPDATE messageBacked by Nakama's native leaderboard (ttt_leaderboard):
Per-player stats (wins, losses, win_streak, best_streak) are additionally stored in Nakama Storage under collection player_stats / key stats for retrieval without leaderboard limitations.
local.yml)logger:
level: "DEBUG" # Change to "warn" in production
session:
token_expiry_sec: 7200
socket:
max_message_size_bytes: 4096
Why does this work? Each browser gets its own device ID (UUID stored in
localStorage), so Nakama treats them as different users.
Nakama.socket.sendMatchState(Nakama.matchID, 4, JSON.stringify({position: 0}))
If it is not your turn, the server responds with OpCode 5 (REJECTED) — the board does not change.(await Nakama.getLeaderboard()).forEach(r => console.log(r))
Access the server admin panel at http://localhost:7351 (local) or port 7351 on your server.
player_stats recordsttt_leaderboard records