60 Commits

Author SHA1 Message Date
70669fc856 Update README.md 2025-12-01 08:33:28 +00:00
1752bfaed4 Update README.md 2025-12-01 08:33:01 +00:00
74e75de577 Update README.md 2025-12-01 08:32:29 +00:00
7983be56e4 Update README.md 2025-12-01 08:27:57 +00:00
e9eb37f665 fixes 2025-11-30 02:06:50 +05:30
7a7c6adc47 updated report 2025-11-30 02:04:41 +05:30
ae5628f370 proper pipeline
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-30 01:26:56 +05:30
e8e2419537 proper pipeline
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-11-30 01:20:02 +05:30
4ee6027612 fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:58:52 +05:30
389e77e2ae fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:54:51 +05:30
7ce860db96 copy local.yml
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-29 23:51:50 +05:30
92f307d33d copy local.yml
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:47:57 +05:30
6e7d2d9f14 fixes
All checks were successful
continuous-integration/drone Build is passing
2025-11-29 22:32:59 +05:30
c91dba475c fixes 2025-11-29 22:23:43 +05:30
04d988c584 fixes 2025-11-29 22:23:37 +05:30
27abc56a00 fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 22:23:23 +05:30
333b48ad60 fixes
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 22:16:25 +05:30
4dee0bfb0a fixes
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-29 22:12:52 +05:30
1e91825808 buildx no push and use arm64 everywhere
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-29 22:10:33 +05:30
c5cb1047ae buildx no push and use
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 21:59:49 +05:30
18f9eed71d no buildx and push
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 21:45:50 +05:30
02de328bcd fixes
Some checks reported errors
continuous-integration/drone/push Build is failing
continuous-integration/drone Build was killed
2025-11-29 21:35:36 +05:30
31f1a66fed arm64 images
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-29 21:28:26 +05:30
5065145c6f direct deploy
Some checks reported errors
continuous-integration/drone/push Build was killed
2025-11-29 21:27:09 +05:30
8e0475c8a7 build arm64 image
Some checks failed
continuous-integration/drone/tag Build is failing
2025-11-29 21:18:54 +05:30
1359a75214 allow games.aetoskia.com
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:41:33 +05:30
6786547950 enabled tag based deployment
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:06:11 +05:30
17a2caea49 fixes
Some checks failed
continuous-integration/drone Build is failing
2025-11-29 19:02:01 +05:30
8ff199ca10 fixes
Some checks failed
continuous-integration/drone Build is failing
2025-11-29 18:59:02 +05:30
62c1f3920b fixes
Some checks failed
continuous-integration/drone/tag Build is failing
2025-11-29 18:42:41 +05:30
73e3f0a7ac fixes
Some checks failed
continuous-integration/drone/tag Build is failing
2025-11-29 18:38:13 +05:30
21f6698b89 fixes
Some checks failed
continuous-integration/drone/tag Build is failing
2025-11-29 18:33:15 +05:30
06904143d6 drone deployment
Some checks reported errors
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/tag Build encountered an error
2025-11-29 18:27:26 +05:30
ba05aa7c76 removed reference to local.yml as it's never used and passing server_key 2025-11-29 18:27:10 +05:30
bfcf977e13 ignoring .env as it'll be secret 2025-11-29 17:34:24 +05:30
6eca090046 deployment changes for DB_ADDR via env var 2025-11-29 17:25:13 +05:30
07cb519f55 added dockerfile for nakama 2025-11-29 16:59:33 +05:30
840d7701ca UI updated 2025-11-28 20:09:15 +05:30
f1f40f5799 backend update 2025-11-28 19:52:16 +05:30
0d8d3785fc username in leaderboard 2025-11-28 19:42:06 +05:30
65b5ef4660 leaderboard 2025-11-28 19:26:47 +05:30
95381c2a56 feat(matchmaking): enable mode-based matchmaking and improve match initialization
- Added matchmaker configuration to local.yml with support for string property "mode"
- Enabled faster matchmaking via interval_ms and large ticket limit
- Broadcast initial match state when both players join
- Added detailed validation and logging for move processing
- Broadcast game-over and forfeit states immediately on player leave
- Improved MatchLoop robustness with change tracking and clearer diagnostics
2025-11-28 14:14:03 +05:30
ead7ad2c35 added check for allowing only class and blitz game mode at server 2025-11-27 16:10:04 +05:30
4a833dc258 fixes 2025-11-27 15:36:39 +05:30
37c090cf64 fixes 2025-11-27 15:15:57 +05:30
7bcdc76594 minor fixes 2025-11-26 17:36:58 +05:30
087616a67e fix(matchmaking): correctly group players only after successful match join
- Track matches based solely on `player.match_id`
- Avoid double-counting from presence events or server broadcasts
- Ensure `matches[match_id]` contains only actual participants
- Prevent false 3–4 player matches and scenario execution failures
- Maintain safety check for non-1v1 match sizes

This resolves incorrect match grouping in automated matchmaking tests,
allowing clean 1v1 scenario execution and accurate match counts.
2025-11-26 17:36:49 +05:30
10058710fb Improve client matchmaking flow with ticket handling, auto-join, and mode distribution
### PlayerWebSocketHandler updates
- Track `ticket` and `match_id` per player instance
- Handle `matchmaker_ticket` messages and store ticket
- Handle `matchmaker_matched` and automatically join created match
- Enhance matchmaking debug output
- Update join_matchmaking() to include mode-based string_properties + query

### Matchmaking simulation improvements
- Evenly distribute players between "classic" and "blitz" modes
- Randomize assignment order to simulate real queue behavior
- Log player→mode mapping for visibility during tests

Example:
  player_0 -> classic
  player_3 -> blitz
  player_5 -> classic

Client test harness now accurately reflects multi-mode matchmaking behavior.
2025-11-26 17:11:20 +05:30
eb35ccd180 Enhance matchmaker to validate mode and create authoritative matches
- Implement MatchmakerMatched callback for true matchmaking flow
- Enforce strict 1v1 pairing (ignore non-2 player matches)
- Read `mode` from matchmaker ticket properties
- Prevent mismatched-mode players from being paired
- Automatically create authoritative `tictactoe` match when valid pair found
- Provide match parameters so match handler receives selected mode
- Improve logging for debugging and visibility

Ensures clean, mode-aware matchmaking queues and proper server-side match creation.
2025-11-26 17:09:49 +05:30
bd376123b3 refactor(matchmaking): migrate Python simulator to native Nakama matchmaker
### Summary
Replaced legacy RPC-based matchmaking flow with proper WebSocket-driven
matchmaker integration. Player simulation now queues via
`matchmaker_add`, auto-joins matches on `matchmaker_matched`, and no
longer depends on `rpc_find_match`.
2025-11-26 16:35:19 +05:30
ea1a70b212 feat(matchmaking): replace legacy rpc_find_match with Nakama native matchmaking
### Summary
This update removes the old RPC-driven matchmaking flow and replaces it
with proper Nakama matchmaker integration. Players now queue using
`matchmaker_add` over WebSockets, and matches are created via
`MatchmakerMatched` callback.

### Changes
- Removed `rpc_find_match` and MatchList polling logic
- Added `MatchmakerMatched` handler to auto-create TicTacToe matches
- Added RPC stubs `join_matchmaking` & `leave_matchmaking` only for
  optional validation (no server-side queueing)
- Updated `main.go` to register:
   `tictactoe` authoritative match
   `matchmaker_matched` callback
   removed obsolete rpc_find_match registration
- Ensured module loads successfully with cleaner InitModule
- Cleaned unused imports and outdated Nakama calls

### Benefits
- Fully scalable & production-ready matchmaking flow
- Eliminates race conditions & manual match assignment
- Supports multiple queues (classic / blitz) via string properties
- Aligns plugin with Nakama best practices
- Enables Python/WebSocket simulation without RPC dependencies
2025-11-26 16:34:55 +05:30
908eebefdd basic matchmaking flow 2025-11-26 16:09:58 +05:30
0fb448dd45 feat: add rpc_find_match for basic 2-player matchmaking
- Implement rpc_find_match Nakama RPC function
- Search for existing authoritative TicTacToe matches via MatchList
- Return first match with available slot (size < 2)
- Create new match using MatchCreate when none available
- Add request/response structs for future extensibility
- Log match search, selection, and creation flow
- Gracefully handle optional JSON payload and invalid input
2025-11-26 16:09:48 +05:30
fa4d4d00be setup player 2025-11-26 16:04:27 +05:30
22993d6d37 minor prints 2025-11-26 15:57:56 +05:30
de4bfb6c07 changed sequence of join match and listener 2025-11-26 15:57:44 +05:30
d260c9a1ef refactor: abstract WebSocket handling into shared base class
- Introduce WebSocketHandler for reusable socket lifecycle management:
  • login, connect, close, start_listener
  • continuous receive loop with on_message callback
  • background listener task support

- Create PlayerWebSocketHandler subclass implementing TicTacToe behavior:
  • match_create, match_join, send_move helpers
  • parse match_data opcodes and pretty-print board state

- Move shared logic (auth, recv loop, WS decoding) out of gameplay module
- Simplifies test scenario execution & enables future bot/test clients
- Reduces duplication and improves separation of concerns
2025-11-26 15:43:25 +05:30
1b4e7a5ee0 feat(test): add comprehensive TicTacToe gameplay scenario flows
- Introduce multiple async test paths for simulation-based validation:
  • happy_path (P1 top-row win)
  • p2_wins_diagonal
  • draw_game (full board, no winner)
  • illegal_occupied_cell
  • illegal_out_of_turn
  • illegal_out_of_bounds
  • midgame_disconnect
  • abandoned_lobby (no opponent joins)
  • spam_moves (anti-flood behavior)
  • random_game (stochastic stress playthrough)

- Add TEST_SCENARIOS registry for automated execution
- Improve coverage of server-side match logic, validation, and cleanup
- Enables CI-driven load, rule enforcement, and termination testing
2025-11-26 14:51:10 +05:30
cb23d3c516 feat: add PlayerWebSocket class abstraction for Nakama TicTacToe client
- Introduce PlayerWebSocket class to encapsulate player behavior
- Handle login, websocket connect, message listening, and cleanup
- Add helpers: create_match, join_match, send_move
- Remove global websocket/session handling
- Update main() to use object-oriented player flow
- Improves readability, scalability, and multiplayer orchestration
2025-11-26 14:34:19 +05:30
37b20c6c36 fixes 2025-11-25 19:15:46 +05:30
11 changed files with 1038 additions and 247 deletions

163
.drone.yml Normal file
View File

@@ -0,0 +1,163 @@
---
kind: pipeline
type: docker
name: nakama-server
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
# -----------------------------------------------------
# 1. Fetch latest Tags
# -----------------------------------------------------
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "❌ No git tags found! Cannot continue."
exit 1
fi
# -----------------------------------------------------
# 2. Check existing Nakama Docker image
# -----------------------------------------------------
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if lila-games/nakama-server:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for lila-games/nakama-server:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^lila-games/nakama-server" || echo "(none)"
- |
if docker image inspect lila-games/nakama-server:$IMAGE_TAG > /dev/null 2>&1; then
echo "✅ Docker image lila-games/nakama-server:$IMAGE_TAG already exists — skipping build"
exit 78
else
echo "⚙️ Docker image lila-games/nakama-server:$IMAGE_TAG not found — proceeding to build..."
fi
# -----------------------------------------------------
# 3. Build Nakama Docker image
# -----------------------------------------------------
- name: build-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔨 Building Nakama image lila-games/nakama-server:latest"
- |
docker build --network=host \
-t lila-games/nakama-server:$IMAGE_TAG \
-t lila-games/nakama-server:latest \
/drone/src
# -----------------------------------------------------
# 4. Push Nakama Image
# -----------------------------------------------------
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "🏷️ Tagging images with registry prefix..."
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:latest
- echo "📤 Pushing lila-games/nakama-server:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
- echo "📤 Pushing lila-games/nakama-server:latest ..."
- docker push $REGISTRY_HOST/lila-games/nakama-server:latest
# -----------------------------------------------------
# 5. Stop old Nakama container
# -----------------------------------------------------
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "🛑 Stopping old Nakama container..."
- docker rm -f nakama-server || true
# -----------------------------------------------------
# 6. Run new Nakama container
# -----------------------------------------------------
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
DB_ADDR:
from_secret: POSTGRES_ADDR
SERVER_KEY:
from_secret: SERVER_KEY
REGISTRY_HOST:
from_secret: REGISTRY_HOST
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🚀 Starting Nakama server..."
- |
docker run -d \
--name nakama-server \
-p 7350:7350 \
-p 7351:7351 \
-p 7349:7349 \
--restart always \
--add-host private-pi:192.168.1.111 \
-e DB_ADDR="$DB_ADDR" \
-e SERVER_KEY="$SERVER_KEY" \
lila-games/nakama-server:latest
# -----------------------------------------------------
# Pipeline trigger
# -----------------------------------------------------
trigger:
event:
- tag

3
.gitignore vendored
View File

@@ -2,4 +2,5 @@
.run .run
.venv .venv
/vendor/ /vendor/
/build/ /build/
.env

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# -----------------------------------------------------
# 1. Build the Nakama plugin
# -----------------------------------------------------
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
RUN apt-get update && apt-get install -y \
build-essential \
git
WORKDIR /workspace
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN mkdir -p build && \
CGO_ENABLED=1 go build \
--trimpath \
--buildmode=plugin \
-o build/main.so \
./plugins
# -----------------------------------------------------
# 2. Build final Nakama image
# -----------------------------------------------------
FROM heroiclabs/nakama:3.21.0-arm
# Copy plugin from builder stage
COPY --from=plugin_builder /workspace/build/main.so /nakama/data/modules/main.so
#COPY --from=plugin_builder local.yml /nakama/data/config.yml
# Default Nakama startup (runs migrations + server)
ENTRYPOINT exec /bin/sh -ecx "/nakama/nakama migrate up --database.address \"$DB_ADDR\" && exec /nakama/nakama --database.address \"$DB_ADDR\" --socket.server_key=\"$SERVER_KEY\""

318
README.md
View File

@@ -1,190 +1,272 @@
# ✅ Project Status Report — Multiplayer Tic-Tac-Toe Platform # tic-tac-toe — Authoritative Multiplayer Game Server (Nakama + Go)
**To:** CTO & Technical Interview Panel A production-grade, server-authoritative multiplayer backend built using **Nakama 3**, **Go plugins**, and **PostgreSQL**, deployed via **Drone CI/CD** and exposed securely through **Traefik**.
**Date:** November 25, 2025
**Version:** v0.0.1
--- ---
## **1. Objective** ## 🚀 Overview
Design and implement a lightweight, scalable, server-authoritative multiplayer game system using **Nakama + Go plugins**, supporting authentication, matchmaking, real-time gameplay, and leaderboards — deployable to Google Cloud for demonstration. This repository contains a fully authoritative Tic-Tac-Toe backend built on **Nakama 3.21.0 ARM**, using a custom **Go plugin** to implement game logic, matchmaking, validation, leaderboards, and session management.
The server ensures:
* **Zero client trust**
* **Deterministic gameplay**
* **Secure matchmaking**
* **Validated moves**
* **Authoritative outcomes**
Designed for scalability, extensibility, and cloud deployment (GCP, AWS, or ARM homelab environments).
--- ---
## **2. Current Completion Summary** ## ⭐ Key Features
| Requirement | Status | * **Authoritative Go match handler** (`tictactoe`)
| ---------------------------------------- | -------------------------------- | * Device-ID authentication + JWT session
| Install Nakama + PostgreSQL | ✅ Completed | * Matchmaking queue support
| Custom Go server plugins | ✅ Completed | * Deterministic game validation
| Server-authoritative Tic-Tac-Toe | ✅ Completed | * Match lifecycle handling
| Real-time WebSocket communication | ✅ Completed | * Disconnect handling & forfeit detection
| Device-based authentication | ✅ Completed | * Leaderboards creation + scoring
| JWT-based session management | ✅ Completed | * Production-grade CI/CD with Drone
| Match creation & joining | ✅ Completed | * Automated ARM Docker builds
| Matchmaking queue support | 🟡 Not Started | * Full Traefik routing (HTTP + WebSocket)
| Game state validation & turn enforcement | 🟡 Not Started | * Compatible with x86/ARM/GCP
| Leaderboard/tracking foundation | 🟡 Not Started |
| UI Game Client | 🟡 Not Started |
| Google Cloud deployment | 🟡 Not Started |
**Core backend functionality is complete and stable**
--- ---
## **3. Core Technical Architecture** ## 🧩 Architecture
* **Backend Framework:** Nakama 3.x ### High-Level System Diagram
* **Business Logic:** Custom Go runtime module
* **Database:** PostgreSQL 14 ```mermaid
* **Transport:** WebSockets (real-time) flowchart LR
* **Authentication:** Device-ID based auth → JWT session returned UI[(React + Vite)] --> Traefik
* **State Management:** Server-authoritative, deterministic Traefik --> Nakama[Nakama Server]
* **Protocol:** Nakama RT JSON envelopes Nakama --> Plugin[Go Plugin]
* **Build & Deployment:** Docker-based Nakama --> Postgres[(PostgreSQL)]
Drone[Drone CI/CD] --> Registry[Private Docker Registry]
Registry --> Nakama
```
--- ---
## **4. Authentication** ## 🛠 Tech Stack
Implemented **Nakama device authentication flow**: **Backend**
1. Client provides device UUID * Nakama 3.21.0 ARM
2. Nakama validates & creates account if needed * Go 1.21.6 (plugin ABI compatible)
3. Server responds with **JWT session token** * PostgreSQL 16
4. Client uses JWT for all WebSocket connections * Docker / multi-stage builds
* Drone CI/CD
* Traefik reverse proxy
**Cloud/Infra**
* GCP-ready
* ARM homelab deployments
* Private registry support
--- ---
## **5. Game Server Logic** ## 📦 Repository Structure
Implemented as Go match module: ```
tic-tac-toe/
* Turn-based validation │── plugins/
* Board occupancy checks │ ├── main.go
* Win/draw/forfeit detection │ ├── match.go
* Automatic broadcast of updated state │ └── matchmaking.go
* Graceful match termination
* Prevents cheating & client-side manipulation │── Dockerfile
│── go.mod
Result: │── go.sum
✅ Entire game lifecycle enforced server-side. │── README.md
```
--- ---
## **6. Real-Time Networking** ## 🔌 Registered Server Components
Clients communicate via: ### 📌 Match Handler
* `match_create` Name: **`tictactoe`**
* `match_join`
* `match_data_send` (OpCode 1) → moves
* Broadcast state updates (OpCode 2)
Python WebSocket simulation confirms: Handles:
✅ Move sequencing
✅ Session isolation * Turn validation
✅ Messaging reliability * State updates
✅ Auto-cleanup on disconnect * Win/loss/draw
* Disconnect forfeit
* Broadcasting authoritative state
### 📌 RPCs
| RPC Name | Purpose |
| ----------------------- | ---------------------------------------- |
| **`leave_matchmaking`** | Allows clients to exit matchmaking queue |
### 📌 Matchmaker Hook
| Hook | Purpose |
| ----------------------- | --------------------------------------------------------- |
| **`MatchmakerMatched`** | Validates matchmaker tickets and instantiates a new match |
--- ---
## **7. Matchmaking** ## 🎮 Gameplay Protocol
**NOT STARTED** ### OpCodes
| OpCode | Direction | Meaning |
| ------ | --------------- | ----------------------------- |
| **1** | Client → Server | Submit move `{x, y}` |
| **2** | Server → Client | Full authoritative game state |
--- ---
## **8. Testing & Validation** ## 🧠 Authoritative Flow Diagram
Performed using: ```mermaid
sequenceDiagram
participant C1 as Client 1
participant C2 as Client 2
participant S as Nakama + Go Plugin
* Automated two-client WebSocket game flow - Happy path C1->>S: Matchmaker Join
C2->>S: Matchmaker Join
**PENDING STRESS AND EDGE CASE TESTING** S->>S: MatchmakerMatched()
S-->>C1: Matched
S-->>C2: Matched
C1->>S: OpCode 1 (Move)
S->>S: Validate Move
S-->>C1: OpCode 2 (Updated State)
S-->>C2: OpCode 2 (Updated State)
```
--- ---
## **9. UI Status** ## ⚙️ Build & Development
Not implemented yet — intentionally deferred. ### Build Go Plugin
Planned: ```
CGO_ENABLED=1 go build \
--trimpath \
--buildmode=plugin \
-o build/main.so \
./plugins
```
* Simple browser/mobile client ### Run Nakama Locally
* Display board, turns, win state
* WebSocket integration
* Leaderboard screen
Estimated time: **610 hours** ```
nakama migrate up --database.address "$DB_ADDR"
nakama \
--database.address "$DB_ADDR" \
--socket.server_key="$SERVER_KEY"
```
--- ---
## **10. Leaderboard System** ## 🐳 Docker Build (Multi-Stage)
Backend-ready but not finalized: ```dockerfile
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
✅ Database & Nakama leaderboard APIs available RUN go mod download
✅ Game result reporting planned COPY . .
🟡 Ranking, ELO, win streak logic pending RUN CGO_ENABLED=1 go build --buildmode=plugin -o build/main.so ./plugins
Estimated time: **46 hours** FROM heroiclabs/nakama:3.21.0-arm
COPY --from=plugin_builder /workspace/build/main.so /nakama/data/modules/main.so
ENTRYPOINT ...
```
--- ---
## **11. Google Cloud Deployment (Interview-Scope)** ## 🤖 CI/CD — Drone Pipeline
Goal: **Simple, affordable, demo-ready deployment** Drone performs:
### Planned architecture: 1. Fetch latest Git tag
2. Check if Docker image already exists
3. Build Nakama + plugin
4. Push to private registry
5. Stop old container
6. Deploy new Nakama server automatically
* Highly subjective as of now Full pipeline included in repository (`.drone.yml`).
| Component | GCP Service |
| ------------- | ----------------------------- |
| Nakama server | Compute Engine VM (Docker) |
| PostgreSQL | Cloud SQL (shared tier) |
| Game UI | Cloud Run or Firebase Hosting |
| Networking | Static IP + HTTPS |
| Auth secrets | Secret Manager (optional) |
Estimated setup time: **68 hours**
--- ---
## **12. Risks & Considerations** ## 🌐 Traefik Routing
| Risk | Mitigation | ### HTTPS
| ------------------------ | ------------------------- |
| No UI yet | Prioritized next |
| Only happy path tested | In parallel with UI work |
| Matchmaking incomplete | Clear implementation plan |
| Leaderboard incomplete | Clear implementation plan |
None block demonstration or evaluation. ```
nakama.aetoskia.com
/ → HTTP API
/ws → WebSocket (real-time)
```
Includes:
* CORS rules
* WebSocket upgrade headers
* Certificate resolver
* Secure default middlewares
--- ---
## **13. Next Steps** ## 🔧 Environment Variables
1. Implement browser/mobile UI | Variable | Description |
2. Stress, load and Edge case testing | --------------- | ---------------------------- |
3. Complete match making, leaderboard scoring | `DB_ADDR` | PostgreSQL connection string |
4. Deploy to Google Cloud for public access | `SERVER_KEY` | Nakama server key |
5. Record demo video + documentation | `REGISTRY_HOST` | Private registry |
Estimated remaining effort: **1.52.5 days**
--- ---
## **Executive Summary** ## 📊 Leaderboards
The foundational backend for the multiplayer Tic-Tac-Toe platform is fully implemented, stable, and validated over real-time WebSocket communication. Core features—authentication, session management, game state handling, and authoritative gameplay—are complete and functioning reliably. * Created during server start
* Score: `+1` on win
* Metadata logged (mode, player IDs)
Remaining deliverables, including UI development, matchmaking, extended test coverage, leaderboard logic, and Google Cloud deployment, are intentionally pending to align effort with interview scope and timelines. These are well-defined, low-risk, and can be completed within the estimated timeframe. ---
**The project is technically strong, progressing as planned, and positioned for successful final delivery and demonstration.** ## 🧪 Testing
* Win/loss/draw simulation
* Invalid move rejection
* Disconnect → forfeit
* Load testing matchmaking
---
## 📈 Production Deployment
Supported on:
* ARM homelabs (Raspberry Pi)
* Google Cloud (Compute Engine + Cloud SQL)
* AWS EC2
* Kubernetes (optional)
---
## 🤝 Contributing
1. Fork repo
2. Create feature branch
3. Write tests
4. Submit PR
Coding style: Go fmt + idiomatic Go; follow Nakama plugin constraints.
---

View File

@@ -1,4 +1,3 @@
version: '3'
services: services:
postgres: postgres:
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
@@ -44,8 +43,8 @@ services:
- "/bin/sh" - "/bin/sh"
- "-ecx" - "-ecx"
- > - >
/nakama/nakama migrate up --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable && /nakama/nakama migrate up --database.address "$DB_ADDR" &&
exec /nakama/nakama --config /nakama/data/local.yml --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable exec /nakama/nakama --database.address "$DB_ADDR" --socket.server_key="$SERVER_KEY"
volumes: volumes:
- ./local.yml:/nakama/data/local.yml - ./local.yml:/nakama/data/local.yml
- ./build:/nakama/data/modules - ./build:/nakama/data/modules

View File

@@ -1,6 +1,8 @@
import random
import asyncio import asyncio
import base64 import base64
import json import json
from typing import Awaitable, Callable
import requests import requests
import websockets import websockets
@@ -18,140 +20,338 @@ def print_board(board):
print("\n" + separator.join(rows) + "\n") print("\n" + separator.join(rows) + "\n")
# ---------- Auth ---------- class WebSocketHandler(object):
def __init__(
self,
custom_id: str,
label: str,
):
self.custom_id = custom_id
self.label = label
def login(custom_id: str) -> str: self.token = None
"""Authenticate via custom ID and return Nakama session token (JWT).""" self.ws = None
basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode() self.listener_task = None
r = requests.post(
f"{HOST}/v2/account/authenticate/custom?create=true", async def on_message(self, msg: dict):
headers={"Authorization": f"Basic {basic}"}, raise NotImplementedError("Override me!")
json={"id": custom_id},
) # ---------- Auth & Connect ----------
r.raise_for_status() def login(self):
body = r.json() """Authenticate via custom ID and store token."""
return body["token"] basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode()
r = requests.post(
f"{HOST}/v2/account/authenticate/custom?create=true",
headers={"Authorization": f"Basic {basic}"},
json={"id": self.custom_id},
)
r.raise_for_status()
self.token = r.json()["token"]
return self.token
async def connect(self):
"""Open Nakama WebSocket using stored auth token."""
if not self.token:
self.login()
url = f"{WS}/ws?token={self.token}"
self.ws = await websockets.connect(url)
# ---------- Listener ----------
async def listener(self):
"""Continuously receive events + decode match data."""
try:
while True:
raw = await self.ws.recv()
msg = json.loads(raw)
await self.on_message(msg) # ✅ handoff
except websockets.exceptions.ConnectionClosedOK:
print(f"[{self.label}] WebSocket closed gracefully")
except websockets.exceptions.ConnectionClosedError as e:
print(f"[{self.label}] WebSocket closed with error: {e}")
def start_listener(self):
"""Spawn background task for async message stream."""
if self.ws:
self.listener_task = asyncio.create_task(self.listener())
# ---------- Cleanup ----------
async def close(self):
if self.listener_task:
self.listener_task.cancel()
if self.ws:
await self.ws.close()
print(f"[{self.label}] Connection closed")
# ---------- WebSocket helpers ---------- class PlayerWebSocketHandler(WebSocketHandler):
@classmethod
async def setup_player(
cls,
name: str
) -> 'PlayerWebSocketHandler':
"""Create WS handler, login, connect, start listener."""
handler = cls(name, name)
handler.login()
await handler.connect()
return handler
async def connect(token: str) -> websockets.ClientConnection: def __init__(self, custom_id: str, label: str):
url = f"{WS}/ws?token={token}" super().__init__(custom_id, label)
ws = await websockets.connect(url) self.ticket = None
return ws self.match_id = None
async def on_message(self, msg):
if "matchmaker_matched" in msg:
match_id = msg["matchmaker_matched"]["match_id"]
print(f"[{self.label}] ✅ Match found: {match_id}")
await self.join_match(match_id)
return
if "matchmaker_ticket" in msg:
self.ticket = msg["matchmaker_ticket"]["ticket"]
print(f"[{self.label}] ✅ Received ticket: {self.ticket}")
return
if "match_data" not in msg:
print(f"[{self.label}] {msg}")
return
md = msg["match_data"]
op = int(md.get("op_code", 0))
data = md.get("data")
if not data:
return
payload = json.loads(base64.b64decode(data).decode())
if op == 1:
print(f"[{self.label}] MOVE -> {payload}")
elif op == 2:
print(f"\n[{self.label}] GAME STATE")
# print_board(payload["board"])
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
else:
print(f"[{self.label}] UNKNOWN OPCODE {op}: {payload}")
# ---------- Match Helpers ----------
async def join_matchmaking(self, mode: str = "classic"):
"""Queue into Nakama matchmaker."""
await self.ws.send(json.dumps({
"matchmaker_add": {
"min_count": 2,
"max_count": 2,
"string_properties": {"mode": mode}
}
}))
print(f"[{self.label}] Searching match for mode={mode}...")
async def leave_matchmaking(self, ticket: str):
"""Remove player from Nakama matchmaking queue."""
await self.ws.send(json.dumps({
"matchmaker_remove": {
"ticket": ticket
}
}))
print(f"[{self.label}] Left matchmaking queue")
async def create_match(self) -> str:
await self.ws.send(json.dumps({"match_create": {}}))
msg = json.loads(await self.ws.recv())
match_id = msg["match"]["match_id"]
print(f"[{self.label}] Created match: {match_id}")
return match_id
async def join_match(self, match_id: str):
await self.ws.send(json.dumps({"match_join": {"match_id": match_id}}))
print(f"[{self.label}] Joined match: {match_id}")
self.match_id = match_id
# ---------- Gameplay ----------
async def send_move(self, match_id: str, row: int, col: int):
payload = {"row": row, "col": col}
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
await self.ws.send(json.dumps({
"match_data_send": {
"match_id": match_id,
"op_code": 1,
"data": encoded,
}
}))
print(f"[{self.label}] Sent move: {payload}")
async def listener(ws, label: str): async def happy_path(
"""Log all messages for a given socket.""" match_id: str,
try: p1: PlayerWebSocketHandler,
while True: p2: PlayerWebSocketHandler
raw = await ws.recv() ):
data = json.loads(raw) # Play moves
if "match_data" not in data: await p1.send_move(match_id, 0, 0)
print(f"[{label}] {data}") await asyncio.sleep(0.3)
continue await p2.send_move(match_id, 1, 1)
md = data["match_data"] await asyncio.sleep(0.3)
parse_match_data(md, label) await p1.send_move(match_id, 0, 1)
except websockets.exceptions.ConnectionClosedOK: await asyncio.sleep(0.3)
print(f"[{label}] WebSocket closed cleanly") await p2.send_move(match_id, 2, 2)
return await asyncio.sleep(0.3)
except websockets.exceptions.ConnectionClosedError as e: await p1.send_move(match_id, 0, 2)
print(f"[{label}] WebSocket closed with error: {e}")
return
def parse_match_data(md, label: str):
op = int(md.get("op_code", 0))
# decode base64 payload
payload_json = base64.b64decode(md["data"]).decode()
payload = json.loads(payload_json)
# OpMove → just log move
if op == 1:
print(f"[{label}] MOVE -> {payload}")
return
# OpState → pretty-print board
if op == 2:
print(f"\n[{label}] GAME STATE")
print_board(payload["board"])
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
return
# other opcodes
raise RuntimeError(f"[{label}] UNKNOWN OPCODE {op}: {payload}")
async def send_move(ws, match_id: str, row: int, col: int): async def p2_wins_diagonal(
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data.""" match_id: str,
payload = {"row": row, "col": col} p1: PlayerWebSocketHandler,
# Nakama expects `data` as bytes -> base64 string in JSON p2: PlayerWebSocketHandler
data_bytes = json.dumps(payload).encode("utf-8") ):
data_b64 = base64.b64encode(data_bytes).decode("ascii") await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.3)
msg = { await p2.send_move(match_id, 0, 2)
"match_data_send": { await asyncio.sleep(0.3)
"match_id": match_id, await p1.send_move(match_id, 1, 0)
"op_code": 1, # OpMove await asyncio.sleep(0.3)
"data": data_b64, await p2.send_move(match_id, 1, 1)
} await asyncio.sleep(0.3)
} await p1.send_move(match_id, 2, 1)
await ws.send(json.dumps(msg)) await asyncio.sleep(0.3)
await p2.send_move(match_id, 2, 0) # P2 wins
# ---------- Main flow ---------- async def draw_game(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
moves = [
(p1, 0, 0), (p2, 0, 1),
(p1, 0, 2), (p2, 1, 0),
(p1, 1, 2), (p2, 1, 1),
(p1, 2, 1), (p2, 2, 2),
(p1, 2, 0),
]
for player, r, c in moves:
await player.send_move(match_id, r, c)
await asyncio.sleep(0.25)
async def illegal_occupied_cell(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.3)
# P2 tries same spot → should trigger server rejection
await p2.send_move(match_id, 0, 0)
async def illegal_out_of_turn(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
await p1.send_move(match_id, 2, 2)
await asyncio.sleep(0.3)
# P1 tries again before P2
await p1.send_move(match_id, 1, 1)
async def illegal_out_of_bounds(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
await p1.send_move(match_id, 3, 3) # Invalid indices
async def midgame_disconnect(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.3)
await p2.close() # simulate rage quit
async def abandoned_lobby(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
# No p2.join_match
await asyncio.sleep(5) # test timeout, cleanup, match state
async def spam_moves(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
for _ in range(10):
await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.05)
async def random_game(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
board = {(r, c) for r in range(3) for c in range(3)}
players = [p1, p2]
for i in range(9):
player = players[i % 2]
print(f"[{player.label}] Playing move...")
r, c = random.choice(list(board))
board.remove((r, c))
await player.send_move(match_id, r, c)
await asyncio.sleep(0.25)
TEST_SCENARIOS = [
happy_path,
p2_wins_diagonal,
draw_game,
illegal_occupied_cell,
illegal_out_of_turn,
illegal_out_of_bounds,
midgame_disconnect,
abandoned_lobby,
spam_moves,
random_game,
]
async def main(): async def main():
# 1) Login 2 players # Initialize players (login + connect + start listener)
token1 = login("player_one_123456") p1 = await PlayerWebSocketHandler.setup_player("player_one_123456")
token2 = login("player_two_123456") p2 = await PlayerWebSocketHandler.setup_player("player_two_123456")
# 2) Connect sockets # Start listeners
ws1 = await connect(token1) p1.start_listener()
ws2 = await connect(token2) p2.start_listener()
# 3) Create a match from P1 # Match create + join
await ws1.send(json.dumps({"match_create": {}})) match_id = await p1.create_match()
raw = await ws1.recv() await p2.join_match(match_id)
msg = json.loads(raw)
match_id = msg["match"]["match_id"]
print("Match:", match_id)
# 4) Only P2 explicitly joins (creator is auto-joined) print(f"\n✅ Match ready: {match_id}\n")
await ws2.send(json.dumps({"match_join": {"match_id": match_id}}))
# 5) Start listeners
asyncio.create_task(listener(ws1, "P1"))
asyncio.create_task(listener(ws2, "P2"))
# Give server time to process joins and initial state
await asyncio.sleep(1) await asyncio.sleep(1)
# 6) Play a quick winning game for P1 (X) for test_scenario in TEST_SCENARIOS:
# P1: (0,0) print(f"\n🚀 Running '{test_scenario.__name__}'...\n")
await send_move(ws1, match_id, 0, 0) await test_scenario(match_id, p1, p2)
await asyncio.sleep(0.3)
# P2: (1,1) await asyncio.sleep(1.0)
await send_move(ws2, match_id, 1, 1)
await asyncio.sleep(0.3)
# P1: (0,1) print("\n✅ All scenarios executed.\n")
await send_move(ws1, match_id, 0, 1)
await asyncio.sleep(0.3)
# P2: (2,2) await p1.close()
await send_move(ws2, match_id, 2, 2) await p2.close()
await asyncio.sleep(0.3)
# P1: (0,2) -> X wins by top row
await send_move(ws1, match_id, 0, 2)
# Wait to receive the final state broadcast (OpState = 2)
await asyncio.sleep(2)
await ws1.close()
await ws2.close()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -7,3 +7,10 @@ session:
socket: socket:
max_message_size_bytes: 4096 # reserved buffer max_message_size_bytes: 4096 # reserved buffer
max_request_size_bytes: 131072 max_request_size_bytes: 131072
matchmaker:
max_tickets: 10000
interval_ms: 100
query:
properties:
string:
mode: true

101
match_making_flow.py Normal file
View File

@@ -0,0 +1,101 @@
import asyncio
import random
from game_flow import PlayerWebSocketHandler, TEST_SCENARIOS
async def simulate_matchmaking(num_players: int = 6):
print(f"\n🎮 Spawning {num_players} players...\n")
# 1) Login + connect
players = await asyncio.gather(*[
PlayerWebSocketHandler.setup_player(f"player_{i}")
for i in range(num_players)
])
print("\n✅ All players authenticated + connected\n")
# 2) Start listeners BEFORE matchmaking
for p in players:
p.start_listener()
print("\n👂 WebSocket listeners active\n")
await asyncio.sleep(0.3)
# ✅ 3) Split evenly between classic & blitz
half = num_players // 2
assignments = ["classic"] * half + ["blitz"] * (num_players - half)
random.shuffle(assignments)
print("\n🎯 Queuing players:")
for p, mode in zip(players, assignments):
print(f" - {p.label} -> {mode}")
await asyncio.gather(*[
p.join_matchmaking(mode)
for p, mode in zip(players, assignments)
])
print("\n✅ All players queued — waiting for matches...\n")
# ✅ 4) Collect matches
matches = {}
timeout = 15
start = asyncio.get_event_loop().time()
matches = dict()
while asyncio.get_event_loop().time() - start < timeout:
for p in players:
# print(f'player = {p.label} for match_id = {p.match_id}')
# print(f'players = {len(matches.get(p.match_id, list()))} for match = {p.match_id}')
if p.match_id:
# matches.setdefault(p.match_id, []).append(p)
if p.match_id not in matches:
matches[p.match_id] = [p]
elif p not in matches[p.match_id]:
matches[p.match_id].append(p)
# print(f'player = {p.label} for match = {p.match_id}')
# print(f'players = {len(matches[p.match_id])} for match = {p.match_id}')
# stop early if all assigned
if sum(len(v) for v in matches.values()) >= num_players:
break
await asyncio.sleep(0.25)
print(f"\n✅ Matchmaking complete — {len(matches)} matches formed\n")
# ✅ 5) Assign random scenarios per match & run
tasks = []
for match_id, grouped_players in matches.items():
if len(grouped_players) != 2:
print(f"⚠️ Skipping match {match_id} — not 1v1")
continue
p1, p2 = grouped_players
scenario = random.choice(TEST_SCENARIOS)
print(
f"🎭 Running scenario '{scenario.__name__}'"
f"{p1.label} vs {p2.label} | match={match_id}"
)
tasks.append(asyncio.create_task(
scenario(match_id, p1, p2)
))
# ✅ 6) Wait for all mock games to finish
if tasks:
await asyncio.gather(*tasks)
else:
print("⚠️ No playable matches found")
# ✅ 7) Cleanup connections
print("\n🧹 Closing player connections...\n")
await asyncio.gather(*[p.close() for p in players])
print("\n🏁 Matchmaking test run complete ✅\n")
if __name__ == "__main__":
asyncio.run(simulate_matchmaking(6))

View File

@@ -7,12 +7,13 @@ import (
"github.com/heroiclabs/nakama-common/runtime" "github.com/heroiclabs/nakama-common/runtime"
) )
// Example RPC
func HelloWorld( func HelloWorld(
ctx context.Context, ctx context.Context,
logger runtime.Logger, logger runtime.Logger,
db *sql.DB, db *sql.DB,
nk runtime.NakamaModule, nk runtime.NakamaModule,
payload string, payload string,
) (string, error) { ) (string, error) {
logger.Info("HelloWorld RPC called — payload: %s", payload) logger.Info("HelloWorld RPC called — payload: %s", payload)
return `{"message": "Hello from Go RPC!"}`, nil return `{"message": "Hello from Go RPC!"}`, nil
@@ -34,7 +35,31 @@ func InitModule(
logger.Error("Failed to register RPC: %v", err) logger.Error("Failed to register RPC: %v", err)
return err return err
} }
// Match making
if err := initializer.RegisterRpc("leave_matchmaking", rpcLeaveMatchmaking); err != nil {
logger.Error("RegisterRpc leave_matchmaking failed: %v", err)
return err
}
if err := initializer.RegisterMatchmakerMatched(MatchmakerMatched); err != nil {
logger.Error("RegisterMatchmakerMatched failed: %v", err)
return err
}
err := nk.LeaderboardCreate(
ctx,
"tictactoe", // id
true, // authoritative
"desc", // sortOrder
"incr", // operator
"", // resetSchedule
map[string]interface{}{}, // metadata
)
if err != nil && err.Error() != "Leaderboard ID already exists" {
return err
}
logger.Info("Leaderboard tictactoe ready")
logger.Info("Go module loaded successfully!") logger.Info("Go module loaded successfully!")
return nil return nil
} }

View File

@@ -106,6 +106,21 @@ func (m *TicTacToeMatch) MatchJoin(
} }
logger.Info("MatchJoin: now %d players", len(s.Players)) logger.Info("MatchJoin: now %d players", len(s.Players))
// If we have enough players to start, broadcast initial state immediately
if len(s.Players) == 2 {
stateJSON, err := json.Marshal(s)
if err != nil {
logger.Error("Failed to marshal state on join: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (initial state) failed: %v", err)
} else {
logger.Info("Broadcasted initial state to players")
}
}
}
return s return s
} }
@@ -128,6 +143,18 @@ func (m *TicTacToeMatch) MatchLeave(
s.GameOver = true s.GameOver = true
s.Winner = "forfeit" s.Winner = "forfeit"
logger.Info("MatchLeave: game ended by forfeit") logger.Info("MatchLeave: game ended by forfeit")
// broadcast final state so clients see the forfeit
stateJSON, err := json.Marshal(s)
if err != nil {
logger.Error("Failed to marshal state on leave: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (forfeit) failed: %v", err)
} else {
logger.Info("Broadcasted forfeit state to remaining players")
}
}
} }
return s return s
@@ -151,8 +178,11 @@ func (m *TicTacToeMatch) MatchLoop(
return s return s
} }
changed := false
for _, msg := range messages { for _, msg := range messages {
if msg.GetOpCode() != OpMove { if msg.GetOpCode() != OpMove {
logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode())
continue continue
} }
@@ -162,46 +192,108 @@ func (m *TicTacToeMatch) MatchLoop(
} }
if err := json.Unmarshal(msg.GetData(), &move); err != nil { if err := json.Unmarshal(msg.GetData(), &move); err != nil {
logger.Warn("Invalid move payload: %v", err) logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
continue continue
} }
playerID := msg.GetUserId() playerID := msg.GetUserId()
playerIdx := indexOf(s.Players, playerID) playerIdx := indexOf(s.Players, playerID)
logger.Info("Received move from %s (playerIdx=%d): row=%d col=%d", playerID, playerIdx, move.Row, move.Col)
if playerIdx == -1 {
logger.Warn("Move rejected: player %s not in player list", playerID)
continue
}
if playerIdx != s.Turn { if playerIdx != s.Turn {
// not your turn logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn)
continue continue
} }
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 { if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
logger.Warn("Move rejected: out of bounds (%d,%d)", move.Row, move.Col)
continue continue
} }
if s.Board[move.Row][move.Col] != "" { if s.Board[move.Row][move.Col] != "" {
logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col)
continue continue
} }
symbols := []string{"X", "O"} symbols := []string{"X", "O"}
if playerIdx < 0 || playerIdx >= len(symbols) { if playerIdx < 0 || playerIdx >= len(symbols) {
logger.Warn("Move rejected: invalid player index %d", playerIdx)
continue continue
} }
s.Board[move.Row][move.Col] = symbols[playerIdx]
// Apply move
s.Board[move.Row][move.Col] = symbols[playerIdx]
changed = true
logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col)
// Check win/draw
if winner := checkWinner(s.Board); winner != "" { if winner := checkWinner(s.Board); winner != "" {
s.Winner = winner s.Winner = winner
s.GameOver = true s.GameOver = true
logger.Info("Game over! Winner: %s", winner)
} else if fullBoard(s.Board) { } else if fullBoard(s.Board) {
s.Winner = "draw" s.Winner = "draw"
s.GameOver = true s.GameOver = true
logger.Info("Game over! Draw")
} else { } else {
s.Turn = 1 - s.Turn s.Turn = 1 - s.Turn
logger.Info("Turn advanced to %d", s.Turn)
} }
if s.GameOver {
if s.Winner != "" && s.Winner != "draw" && s.Winner != "forfeit" {
// winner = "X" or "O"
winningIndex := 0
if s.Winner == "O" {
winningIndex = 1
}
winnerUserId := s.Players[winningIndex]
account, acc_err := nk.AccountGetId(ctx, winnerUserId)
winnerUsername := ""
if acc_err != nil {
logger.Error("Failed to fetch username for winner %s: %v", winnerUserId, acc_err)
} else {
winnerUsername = account.GetUser().GetUsername()
}
logger.Info("Winner username=%s userId=%s", winnerUsername, winnerUserId)
// Write +1 win
_, err := nk.LeaderboardRecordWrite(
ctx,
"tictactoe", // leaderboard ID
winnerUserId, // owner ID
winnerUsername, // username
int64(1), // score
int64(0), // subscore
map[string]interface{}{"result": "win"},
nil, // overrideOperator
)
if err != nil {
logger.Error("Failed to write leaderboard win: %v", err)
} else {
logger.Info("Leaderboard updated for: %s", winnerUserId)
}
}
}
} }
// Broadcast updated state to everyone // If anything changed (or periodically if you want), broadcast updated state to everyone
stateJSON, _ := json.Marshal(s) if changed {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil { stateJSON, err := json.Marshal(s)
logger.Error("BroadcastMessage failed: %v", err) if err != nil {
logger.Error("Failed to marshal state: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
} else {
logger.Info("Broadcasted updated state to players")
}
}
} }
return s return s

86
plugins/matchmaking.go Normal file
View File

@@ -0,0 +1,86 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
)
type MatchmakingTicket struct {
UserID string `json:"user_id"`
Mode string `json:"mode"`
}
// MatchmakerMatched is triggered automatically when enough players form a match.
func MatchmakerMatched(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
entries []runtime.MatchmakerEntry,
) (string, error) {
if len(entries) != 2 {
logger.Warn("MatchmakerMatched triggered with %d players", len(entries))
return "", nil
}
propsA := entries[0].GetProperties()
propsB := entries[1].GetProperties()
validModes := map[string]bool{"classic": true, "blitz": true}
modeA, okA := propsA["mode"].(string)
modeB, okB := propsB["mode"].(string)
if !okA || !okB || !validModes[modeA] || !validModes[modeB] {
logger.Warn("MatchmakerMatched missing mode property — ignoring")
return "", nil
}
// ✅ If modes dont match, let Nakama find another pairing
if modeA != modeB {
logger.Warn("Mode mismatch %s vs %s — retrying matchmaking", modeA, modeB)
return "", nil
}
// ✅ Create authoritative match
matchParams := map[string]interface{}{
"mode": modeA,
}
matchID, err := nk.MatchCreate(ctx, "tictactoe", matchParams)
if err != nil {
logger.Error("MatchCreate failed: %v", err)
return "", runtime.NewError("failed to create match", 13)
}
logger.Info("✅ Match created %s — mode=%s", matchID, modeA)
return matchID, nil
}
// RPC to leave matchmaking queue
func rpcLeaveMatchmaking(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
payload string,
) (string, error) {
var input struct {
Ticket string `json:"ticket"`
}
if err := json.Unmarshal([]byte(payload), &input); err != nil {
return "", runtime.NewError("invalid JSON", 3)
}
if input.Ticket == "" {
return "", runtime.NewError("missing ticket", 3)
}
logger.Info("✅ Matchmaking ticket removed: %s", input.Ticket)
return "{}", nil
}