65 Commits
v0.0.1 ... main

Author SHA1 Message Date
1c31c489c7 fixes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-01 18:27:19 +05:30
3eadb49a72 feat(games): unify ApplyMove signatures + remove unused player conversion
Updated TicTacToeRules and BattleshipRules to implement ApplyMove(state, playerIdx, payload) (bool, bool, int) as required by GameRules.

Added win/draw resolution logic directly inside each game’s ApplyMove return.

Removed obsolete convertToGamePlayers helper.

Updated GenericMatch to call AssignPlayerSymbols with []*structs.Player directly.

Ensured all rule implementations now fully satisfy the GameRules interface.
2025-12-01 17:02:57 +05:30
3c81a8bf29 fixed package 2025-12-01 16:18:22 +05:30
d9c3ecb252 refactor(server): move shared game logic into games/ and introduce GameConfig system
- Moved common/game.go → plugins/games/rules.go
  - Contains GameRules interface, MovePayload, Player abstraction
  - Centralizes all reusable game rule contract logic

- Added plugins/games/config.go
  - Introduces GameConfiguration struct (players, board size, etc.)
  - Added global GameConfig and RulesRegistry maps
  - Each game (tictactoe, battleship, etc.) now registers its config + rules

- Updated generic_match.go to use:
  - GameConfig for board/players initialization
  - RulesRegistry for rule lookup during MatchInit
  - Removed hardcoded TicTacToe behavior
  - Clean error returns when game param missing or invalid

- Updated folder structure:
    /plugins/
        /games
            rules.go       (formerly common/game.go)
            config.go
            tictactoe.go
            battleship.go
        /structs
        /modules
        main.go

- Ensures GenericMatch pulls:
    m.GameName, m.Mode, m.Config, m.Rules
  directly from config/registry at MatchInit

- Removes old duplicated logic and simplifies how games are registered
2025-12-01 16:05:53 +05:30
eeb0a8175f feat: refactor Nakama plugin into generic multi-game match engine
### Highlights
- Introduced generic match engine (`generic_match.go`) implementing dynamic GameRules-based runtime.
- Added modular structure under `/plugins`:
  - /plugins/game      → GameRules interface + TicTacToe + Battleship rule sets
  - /plugins/structs   → Board, Player, MatchState generic structs
  - /plugins/modules   → matchmaking + RPC handlers + match engine
- Migrated TicTacToe logic into reusable rule implementation.
- Added Battleship game support using same engine.
- Updated matchmaking to accept { game, mode } for multi-game routing.
- Updated UI contract: clients must send `game` (and optional `mode`) when joining matchmaking.
- Removed hardcoded TicTacToe match registration.
- Registered a single “generic” authoritative match with ruleset registry.
- Normalized imports under local dev module path.
- Ensured MatchState and Board are now generic and reusable across games.
- Added strict requirement for `game` metadata in match flow (error if missing).
- Cleaned initial state creation into MatchInit with flexible board dimensions.
- Improved MatchLeave for proper forfeit handling through GameRules.

### Result
The server now supports an unlimited number of turn-based board games
via swappable rulesets while keeping a single authoritative Nakama match loop.
2025-12-01 15:28:54 +05:30
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
20 changed files with 1845 additions and 544 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
.venv
/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
**Date:** November 25, 2025
**Version:** v0.0.1
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**.
---
## **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 |
| ---------------------------------------- | -------------------------------- |
| Install Nakama + PostgreSQL | ✅ Completed |
| Custom Go server plugins | ✅ Completed |
| Server-authoritative Tic-Tac-Toe | ✅ Completed |
| Real-time WebSocket communication | ✅ Completed |
| Device-based authentication | ✅ Completed |
| JWT-based session management | ✅ Completed |
| Match creation & joining | ✅ Completed |
| Matchmaking queue support | 🟡 Not Started |
| Game state validation & turn enforcement | 🟡 Not Started |
| Leaderboard/tracking foundation | 🟡 Not Started |
| UI Game Client | 🟡 Not Started |
| Google Cloud deployment | 🟡 Not Started |
**Core backend functionality is complete and stable**
* **Authoritative Go match handler** (`tictactoe`)
* Device-ID authentication + JWT session
* Matchmaking queue support
* Deterministic game validation
* Match lifecycle handling
* Disconnect handling & forfeit detection
* Leaderboards creation + scoring
* Production-grade CI/CD with Drone
* Automated ARM Docker builds
* Full Traefik routing (HTTP + WebSocket)
* Compatible with x86/ARM/GCP
---
## **3. Core Technical Architecture**
## 🧩 Architecture
* **Backend Framework:** Nakama 3.x
* **Business Logic:** Custom Go runtime module
* **Database:** PostgreSQL 14
* **Transport:** WebSockets (real-time)
* **Authentication:** Device-ID based auth → JWT session returned
* **State Management:** Server-authoritative, deterministic
* **Protocol:** Nakama RT JSON envelopes
* **Build & Deployment:** Docker-based
### High-Level System Diagram
```mermaid
flowchart LR
UI[(React + Vite)] --> Traefik
Traefik --> Nakama[Nakama Server]
Nakama --> Plugin[Go Plugin]
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
2. Nakama validates & creates account if needed
3. Server responds with **JWT session token**
4. Client uses JWT for all WebSocket connections
* Nakama 3.21.0 ARM
* Go 1.21.6 (plugin ABI compatible)
* PostgreSQL 16
* 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:
* Turn-based validation
* Board occupancy checks
* Win/draw/forfeit detection
* Automatic broadcast of updated state
* Graceful match termination
* Prevents cheating & client-side manipulation
Result:
✅ Entire game lifecycle enforced server-side.
```
tic-tac-toe/
│── plugins/
│ ├── main.go
│ ├── match.go
│ └── matchmaking.go
│── Dockerfile
│── go.mod
│── go.sum
│── README.md
```
---
## **6. Real-Time Networking**
## 🔌 Registered Server Components
Clients communicate via:
### 📌 Match Handler
* `match_create`
* `match_join`
* `match_data_send` (OpCode 1) → moves
* Broadcast state updates (OpCode 2)
Name: **`tictactoe`**
Python WebSocket simulation confirms:
✅ Move sequencing
✅ Session isolation
✅ Messaging reliability
✅ Auto-cleanup on disconnect
Handles:
* Turn validation
* State updates
* 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
**PENDING STRESS AND EDGE CASE TESTING**
C1->>S: Matchmaker Join
C2->>S: Matchmaker Join
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
* Display board, turns, win state
* WebSocket integration
* Leaderboard screen
### Run Nakama Locally
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
✅ Game result reporting planned
🟡 Ranking, ELO, win streak logic pending
RUN go mod download
COPY . .
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
| 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**
Full pipeline included in repository (`.drone.yml`).
---
## **12. Risks & Considerations**
## 🌐 Traefik Routing
| Risk | Mitigation |
| ------------------------ | ------------------------- |
| No UI yet | Prioritized next |
| Only happy path tested | In parallel with UI work |
| Matchmaking incomplete | Clear implementation plan |
| Leaderboard incomplete | Clear implementation plan |
### HTTPS
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
2. Stress, load and Edge case testing
3. Complete match making, leaderboard scoring
4. Deploy to Google Cloud for public access
5. Record demo video + documentation
Estimated remaining effort: **1.52.5 days**
| Variable | Description |
| --------------- | ---------------------------- |
| `DB_ADDR` | PostgreSQL connection string |
| `SERVER_KEY` | Nakama server key |
| `REGISTRY_HOST` | Private registry |
---
## **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:
postgres:
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
@@ -44,8 +43,8 @@ services:
- "/bin/sh"
- "-ecx"
- >
/nakama/nakama migrate up --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable &&
exec /nakama/nakama --config /nakama/data/local.yml --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable
/nakama/nakama migrate up --database.address "$DB_ADDR" &&
exec /nakama/nakama --database.address "$DB_ADDR" --socket.server_key="$SERVER_KEY"
volumes:
- ./local.yml:/nakama/data/local.yml
- ./build:/nakama/data/modules

View File

@@ -1,6 +1,8 @@
import random
import asyncio
import base64
import json
from typing import Awaitable, Callable
import requests
import websockets
@@ -18,140 +20,338 @@ def print_board(board):
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:
"""Authenticate via custom ID and return Nakama session token (JWT)."""
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": custom_id},
)
r.raise_for_status()
body = r.json()
return body["token"]
self.token = None
self.ws = None
self.listener_task = None
async def on_message(self, msg: dict):
raise NotImplementedError("Override me!")
# ---------- Auth & Connect ----------
def login(self):
"""Authenticate via custom ID and store 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:
url = f"{WS}/ws?token={token}"
ws = await websockets.connect(url)
return ws
def __init__(self, custom_id: str, label: str):
super().__init__(custom_id, label)
self.ticket = None
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):
"""Log all messages for a given socket."""
try:
while True:
raw = await ws.recv()
data = json.loads(raw)
if "match_data" not in data:
print(f"[{label}] {data}")
continue
md = data["match_data"]
parse_match_data(md, label)
except websockets.exceptions.ConnectionClosedOK:
print(f"[{label}] WebSocket closed cleanly")
return
except websockets.exceptions.ConnectionClosedError as e:
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 happy_path(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
# Play moves
await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.3)
await p2.send_move(match_id, 1, 1)
await asyncio.sleep(0.3)
await p1.send_move(match_id, 0, 1)
await asyncio.sleep(0.3)
await p2.send_move(match_id, 2, 2)
await asyncio.sleep(0.3)
await p1.send_move(match_id, 0, 2)
async def send_move(ws, match_id: str, row: int, col: int):
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data."""
payload = {"row": row, "col": col}
# Nakama expects `data` as bytes -> base64 string in JSON
data_bytes = json.dumps(payload).encode("utf-8")
data_b64 = base64.b64encode(data_bytes).decode("ascii")
msg = {
"match_data_send": {
"match_id": match_id,
"op_code": 1, # OpMove
"data": data_b64,
}
}
await ws.send(json.dumps(msg))
async def p2_wins_diagonal(
match_id: str,
p1: PlayerWebSocketHandler,
p2: PlayerWebSocketHandler
):
await p1.send_move(match_id, 0, 0)
await asyncio.sleep(0.3)
await p2.send_move(match_id, 0, 2)
await asyncio.sleep(0.3)
await p1.send_move(match_id, 1, 0)
await asyncio.sleep(0.3)
await p2.send_move(match_id, 1, 1)
await asyncio.sleep(0.3)
await p1.send_move(match_id, 2, 1)
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():
# 1) Login 2 players
token1 = login("player_one_123456")
token2 = login("player_two_123456")
# Initialize players (login + connect + start listener)
p1 = await PlayerWebSocketHandler.setup_player("player_one_123456")
p2 = await PlayerWebSocketHandler.setup_player("player_two_123456")
# 2) Connect sockets
ws1 = await connect(token1)
ws2 = await connect(token2)
# Start listeners
p1.start_listener()
p2.start_listener()
# 3) Create a match from P1
await ws1.send(json.dumps({"match_create": {}}))
raw = await ws1.recv()
msg = json.loads(raw)
match_id = msg["match"]["match_id"]
print("Match:", match_id)
# Match create + join
match_id = await p1.create_match()
await p2.join_match(match_id)
# 4) Only P2 explicitly joins (creator is auto-joined)
await ws2.send(json.dumps({"match_join": {"match_id": match_id}}))
print(f"\n✅ Match ready: {match_id}\n")
# 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)
# 6) Play a quick winning game for P1 (X)
# P1: (0,0)
await send_move(ws1, match_id, 0, 0)
await asyncio.sleep(0.3)
for test_scenario in TEST_SCENARIOS:
print(f"\n🚀 Running '{test_scenario.__name__}'...\n")
await test_scenario(match_id, p1, p2)
# P2: (1,1)
await send_move(ws2, match_id, 1, 1)
await asyncio.sleep(0.3)
await asyncio.sleep(1.0)
# P1: (0,1)
await send_move(ws1, match_id, 0, 1)
await asyncio.sleep(0.3)
print("\n✅ All scenarios executed.\n")
# P2: (2,2)
await send_move(ws2, match_id, 2, 2)
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()
await p1.close()
await p2.close()
if __name__ == "__main__":

4
go.mod
View File

@@ -1,7 +1,7 @@
module git.aetoskia.com/lila-games/tic-tac-toe
module localrepo
go 1.21
require github.com/heroiclabs/nakama-common v1.31.0
require google.golang.org/protobuf v1.31.0 // indirect
require google.golang.org/protobuf v1.31.0

View File

@@ -7,3 +7,10 @@ session:
socket:
max_message_size_bytes: 4096 # reserved buffer
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))

183
plugins/games/battleship.go Normal file
View File

@@ -0,0 +1,183 @@
package games
import (
"fmt"
"encoding/json"
"localrepo/plugins/structs"
)
//
// BATTLESHIP RULES IMPLEMENTATION
//
// NOTES:
// - 2 players
// - Each player has 2 boards:
// 1. Their own ship board (state.Board is not reused here)
// 2. Their "shots" board (hits/misses on opponent)
// - We store boards in Player.Metadata as JSON strings
// (simplest method without changing your structs).
//
// ShipBoard and ShotBoard are encoded inside Metadata:
//
// Metadata["ship_board"] = JSON string of [][]string
// Metadata["shot_board"] = JSON string of [][]string
//
// ------------------------------
// Helpers: encode/decode
// ------------------------------
func encodeBoard(b [][]string) string {
out := "["
for i, row := range b {
out += "["
for j, col := range row {
out += fmt.Sprintf("%q", col)
if j < len(row)-1 {
out += ","
}
}
out += "]"
if i < len(b)-1 {
out += ","
}
}
out += "]"
return out
}
func decodeBoard(s string) [][]string {
var out [][]string
// should never fail; safe fallback
_ = json.Unmarshal([]byte(s), &out)
return out
}
// ------------------------------
// BattleshipRules
// ------------------------------
type BattleshipRules struct{}
func (b *BattleshipRules) MaxPlayers() int { return 2 }
// ------------------------------
// Assign player boards
// ------------------------------
func (b *BattleshipRules) AssignPlayerSymbols(players []*structs.Player) {
// Battleship has no symbols like X/O,
// but we use this hook to initialize per-player boards.
for _, p := range players {
// 10x10 boards
empty := make([][]string, 10)
for r := range empty {
empty[r] = make([]string, 10)
}
// ship board → players place ships manually via a "setup" phase
p.Metadata["ship_board"] = encodeBoard(empty)
// shot board → empty grid that tracks hits/misses
p.Metadata["shot_board"] = encodeBoard(empty)
}
}
// ------------------------------
// ValidateMove
// payload.data = { "row": int, "col": int }
// ------------------------------
func (b *BattleshipRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
rF, ok1 := payload.Data["row"].(float64)
cF, ok2 := payload.Data["col"].(float64)
if !ok1 || !ok2 {
return false
}
r := int(rF)
c := int(cF)
if r < 0 || r > 9 || c < 0 || c > 9 {
return false
}
// Check if this spot was already shot before
shotBoard := decodeBoard(state.Players[playerIdx].Metadata["shot_board"])
return shotBoard[r][c] == ""
}
// ------------------------------
// ApplyMove
// ------------------------------
func (b *BattleshipRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int) {
attacker := state.Players[playerIdx]
defenderIdx := 1 - playerIdx
defender := state.Players[defenderIdx]
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
shotBoard := decodeBoard(attacker.Metadata["shot_board"])
shipBoard := decodeBoard(defender.Metadata["ship_board"])
if shipBoard[r][c] == "S" {
shotBoard[r][c] = "H"
shipBoard[r][c] = "X"
} else {
shotBoard[r][c] = "M"
}
attacker.Metadata["shot_board"] = encodeBoard(shotBoard)
defender.Metadata["ship_board"] = encodeBoard(shipBoard)
over, winner := b.CheckGameOver(state)
return true, over, winner
}
// ------------------------------
// CheckGameOver
// ------------------------------
func (b *BattleshipRules) CheckGameOver(state *structs.MatchState) (bool, int) {
for i, p := range state.Players {
ships := decodeBoard(p.Metadata["ship_board"])
alive := false
for r := range ships {
for c := range ships[r] {
if ships[r][c] == "S" {
alive = true
}
}
}
if !alive {
// this player has no ships left → opponent wins
return true, 1 - i
}
}
return false, -1
}
// ------------------------------
// Forfeit Winner
// ------------------------------
func (b *BattleshipRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int {
// If player leaves, opponent automatically wins.
if leaverIndex == 0 {
return 1
}
if leaverIndex == 1 {
return 0
}
return -1
}

23
plugins/games/config.go Normal file
View File

@@ -0,0 +1,23 @@
package games
type BoardConfig struct {
Rows int
Cols int
}
type GameConfiguration struct {
Players int
Board BoardConfig
}
// Static configuration for all supported games.
var GameConfig = map[string]GameConfiguration{
"tictactoe": {
Players: 2,
Board: BoardConfig{Rows: 3, Cols: 3},
},
"battleship": {
Players: 2,
Board: BoardConfig{Rows: 10, Cols: 10},
},
}

27
plugins/games/rules.go Normal file
View File

@@ -0,0 +1,27 @@
package games
import "localrepo/plugins/structs"
// MovePayload is used for incoming move data from clients.
type MovePayload struct {
Data map[string]interface{} `json:"data"`
}
type GameRules interface {
// Number of players needed to start.
MaxPlayers() int
// Assign symbols/colors/pieces at start.
AssignPlayerSymbols(players []*structs.Player)
// Apply a move.
// Returns: (changed, gameOver, winnerIndex)
ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) (bool, bool, int)
// If a player leaves, who wins?
// Return:
// >=0 → winner index
// -1 → draw
// -2 → invalid
ForfeitWinner(state *structs.MatchState, leaverIndex int) int
}

View File

@@ -0,0 +1,149 @@
package games
import (
"localrepo/plugins/structs"
)
// TicTacToeRules implements GameRules for 3x3 Tic Tac Toe.
type TicTacToeRules struct{}
// -------------------------------
// GameRules Implementation
// -------------------------------
func (t *TicTacToeRules) MaxPlayers() int {
return 2
}
// Assign player symbols: X and O
func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) {
if len(players) < 2 {
return
}
players[0].Metadata["symbol"] = "X"
players[1].Metadata["symbol"] = "O"
}
// ValidateMove checks bounds and empty cell.
func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
rowVal, ok1 := payload.Data["row"]
colVal, ok2 := payload.Data["col"]
if !ok1 || !ok2 {
return false
}
row, ok3 := rowVal.(float64)
col, ok4 := colVal.(float64)
if !ok3 || !ok4 {
return false
}
r := int(row)
c := int(col)
// bounds
if !state.Board.InBounds(r, c) {
return false
}
// empty?
return state.Board.IsEmpty(r, c)
}
// ApplyMove writes X or O to the board.
func (t *TicTacToeRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int) {
symbol := state.Players[playerIdx].Metadata["symbol"]
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
state.Board.Set(r, c, symbol)
over, winner := t.CheckGameOver(state)
return true, over, winner
}
// CheckGameOver determines win/draw state.
func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) {
winnerSymbol := t.findWinner(state.Board)
if winnerSymbol != "" {
// find the player with this symbol
for _, p := range state.Players {
if p.Metadata["symbol"] == winnerSymbol {
return true, p.Index
}
}
return true, -1
}
if state.Board.Full() {
return true, -1 // draw
}
return false, -1
}
// OnForfeit: whoever leaves loses instantly
func (t *TicTacToeRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int {
// If player 0 leaves, player 1 wins.
if leaverIndex == 0 && len(state.Players) > 1 {
return 1
}
// If player 1 leaves, player 0 wins.
if leaverIndex == 1 && len(state.Players) > 0 {
return 0
}
// Otherwise draw.
return -1
}
// -------------------------------
// Helper: winner detection
// -------------------------------
func (t *TicTacToeRules) findWinner(b *structs.Board) string {
lines := [][][2]int{
// rows
{{0, 0}, {0, 1}, {0, 2}},
{{1, 0}, {1, 1}, {1, 2}},
{{2, 0}, {2, 1}, {2, 2}},
// cols
{{0, 0}, {1, 0}, {2, 0}},
{{0, 1}, {1, 1}, {2, 1}},
{{0, 2}, {1, 2}, {2, 2}},
// diagonals
{{0, 0}, {1, 1}, {2, 2}},
{{0, 2}, {1, 1}, {2, 0}},
}
for _, line := range lines {
r1, c1 := line[0][0], line[0][1]
r2, c2 := line[1][0], line[1][1]
r3, c3 := line[2][0], line[2][1]
v1 := b.Get(r1, c1)
if v1 != "" &&
v1 == b.Get(r2, c2) &&
v1 == b.Get(r3, c3) {
return v1
}
}
return ""
}

View File

@@ -5,36 +5,83 @@ import (
"database/sql"
"github.com/heroiclabs/nakama-common/runtime"
// Project modules
"localrepo/plugins/modules"
"localrepo/plugins/games"
)
func HelloWorld(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
payload string,
) (string, error) {
logger.Info("HelloWorld RPC called — payload: %s", payload)
return `{"message": "Hello from Go RPC!"}`, nil
}
// Required module initializer
func InitModule(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
initializer runtime.Initializer,
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
initializer runtime.Initializer,
) error {
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
logger.Error("Failed to register RPC: %v", err)
//--------------------------------------------------------
// 1. Register RPCs
//--------------------------------------------------------
if err := initializer.RegisterRpc("leave_matchmaking", modules.RpcLeaveMatchmaking); err != nil {
logger.Error("Failed to register RPC leave_matchmaking: %v", err)
return err
}
if err := initializer.RegisterMatch("tictactoe", NewMatch); err != nil {
logger.Error("Failed to register RPC: %v", err)
//--------------------------------------------------------
// 2. Register Matchmaker Handler
//--------------------------------------------------------
if err := initializer.RegisterMatchmakerMatched(modules.MatchmakerMatched); err != nil {
logger.Error("Failed to register MatchmakerMatched: %v", err)
return err
}
//--------------------------------------------------------
// 3. Register ALL game rules for GenericMatch
//--------------------------------------------------------
registry := map[string]games.GameRules{
"tictactoe": &games.TicTacToeRules{},
"battleship": &games.BattleshipRules{},
}
if err := initializer.RegisterMatch(
"generic",
modules.NewGenericMatch(registry),
); err != nil {
logger.Error("Failed to register generic match: %v", err)
return err
}
//--------------------------------------------------------
// 4. Create Leaderboards
//--------------------------------------------------------
leaderboards := []string{
"tictactoe_classic",
"tictactoe_ranked",
"battleship_classic",
"battleship_ranked",
}
for _, lb := range leaderboards {
err := nk.LeaderboardCreate(
ctx,
lb,
true, // authoritative
"desc", // sort order
"incr", // operator
"", // reset schedule
map[string]interface{}{}, // metadata
)
if err != nil && err.Error() != "Leaderboard ID already exists" {
logger.Error("Failed to create leaderboard %s: %v", lb, err)
return err
}
logger.Info("Leaderboard ready: %s", lb)
}
logger.Info("Go module loaded successfully!")
return nil
}

View File

@@ -1,286 +0,0 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
)
const (
OpMove int64 = 1
OpState int64 = 2
)
// Server-side game state
type MatchState struct {
Board [3][3]string `json:"board"`
Players []string `json:"players"`
Turn int `json:"turn"` // index in Players
Winner string `json:"winner"` // "X", "O", "draw", "forfeit"
GameOver bool `json:"game_over"` // true when finished
}
// Struct that implements runtime.Match
type TicTacToeMatch struct{}
// Factory for RegisterMatch
func NewMatch(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
) (runtime.Match, error) {
logger.Info("TicTacToe NewMatch factory called")
return &TicTacToeMatch{}, nil
}
// ---- MatchInit ----
// Return initial state, tick rate (ticks/sec), and label
func (m *TicTacToeMatch) MatchInit(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
params map[string]interface{},
) (interface{}, int, string) {
state := &MatchState{
Board: [3][3]string{},
Players: []string{},
Turn: 0,
Winner: "",
GameOver: false,
}
tickRate := 5 // 5 ticks per second (~200ms)
label := "tictactoe"
logger.Info("TicTacToe MatchInit: tickRate=%v label=%s", tickRate, label)
return state, tickRate, label
}
// ---- MatchJoinAttempt ----
func (m *TicTacToeMatch) MatchJoinAttempt(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presence runtime.Presence,
metadata map[string]string,
) (interface{}, bool, string) {
s := state.(*MatchState)
if len(s.Players) >= 2 {
return s, false, "match full"
}
return s, true, ""
}
// ---- MatchJoin ----
func (m *TicTacToeMatch) MatchJoin(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presences []runtime.Presence,
) interface{} {
s := state.(*MatchState)
for _, p := range presences {
userID := p.GetUserId()
// avoid duplicates
if indexOf(s.Players, userID) == -1 {
s.Players = append(s.Players, userID)
}
}
logger.Info("MatchJoin: now %d players", len(s.Players))
return s
}
// ---- MatchLeave ----
func (m *TicTacToeMatch) MatchLeave(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presences []runtime.Presence,
) interface{} {
s := state.(*MatchState)
// End the game if anyone leaves
if !s.GameOver {
s.GameOver = true
s.Winner = "forfeit"
logger.Info("MatchLeave: game ended by forfeit")
}
return s
}
// ---- MatchLoop ----
func (m *TicTacToeMatch) MatchLoop(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
messages []runtime.MatchData,
) interface{} {
s := state.(*MatchState)
if s.GameOver {
return s
}
for _, msg := range messages {
if msg.GetOpCode() != OpMove {
continue
}
var move struct {
Row int `json:"row"`
Col int `json:"col"`
}
if err := json.Unmarshal(msg.GetData(), &move); err != nil {
logger.Warn("Invalid move payload: %v", err)
continue
}
playerID := msg.GetUserId()
playerIdx := indexOf(s.Players, playerID)
if playerIdx != s.Turn {
// not your turn
continue
}
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
continue
}
if s.Board[move.Row][move.Col] != "" {
continue
}
symbols := []string{"X", "O"}
if playerIdx < 0 || playerIdx >= len(symbols) {
continue
}
s.Board[move.Row][move.Col] = symbols[playerIdx]
if winner := checkWinner(s.Board); winner != "" {
s.Winner = winner
s.GameOver = true
} else if fullBoard(s.Board) {
s.Winner = "draw"
s.GameOver = true
} else {
s.Turn = 1 - s.Turn
}
}
// Broadcast updated state to everyone
stateJSON, _ := json.Marshal(s)
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
}
return s
}
// ---- MatchTerminate ----
func (m *TicTacToeMatch) MatchTerminate(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
graceSeconds int,
) interface{} {
logger.Info("MatchTerminate: grace=%d", graceSeconds)
return state
}
// ---- MatchSignal (not used, but required) ----
func (m *TicTacToeMatch) MatchSignal(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
data string,
) (interface{}, string) {
logger.Info("MatchSignal: %s", data)
// no-op; just echo back
return state, ""
}
// ---- Helpers ----
func indexOf(arr []string, v string) int {
for i, s := range arr {
if s == v {
return i
}
}
return -1
}
func checkWinner(b [3][3]string) string {
lines := [][][2]int{
{{0, 0}, {0, 1}, {0, 2}},
{{1, 0}, {1, 1}, {1, 2}},
{{2, 0}, {2, 1}, {2, 2}},
{{0, 0}, {1, 0}, {2, 0}},
{{0, 1}, {1, 1}, {2, 1}},
{{0, 2}, {1, 2}, {2, 2}},
{{0, 0}, {1, 1}, {2, 2}},
{{0, 2}, {1, 1}, {2, 0}},
}
for _, l := range lines {
a, b2, c := l[0], l[1], l[2]
if b[a[0]][a[1]] != "" &&
b[a[0]][a[1]] == b[b2[0]][b2[1]] &&
b[a[0]][a[1]] == b[c[0]][c[1]] {
return b[a[0]][a[1]]
}
}
return ""
}
func fullBoard(b [3][3]string) bool {
for _, row := range b {
for _, v := range row {
if v == "" {
return false
}
}
}
return true
}

389
plugins/modules/match.go Normal file
View File

@@ -0,0 +1,389 @@
package modules
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/heroiclabs/nakama-common/runtime"
"localrepo/plugins/structs"
"localrepo/plugins/games"
)
const (
OpMove int64 = 1
OpState int64 = 2
)
// GenericMatch is a match implementation that delegates game-specific logic
// to a game.GameRules implementation chosen by the match params ("game").
type GenericMatch struct {
// Registry provided when creating the match factory. Keeps available rules.
Registry map[string]games.GameRules
GameName string
Mode string
Config games.GameConfiguration
Rules games.GameRules
}
// NewGenericMatch returns a factory function suitable for RegisterMatch.
// Provide a registry mapping game names (strings) to implementations.
func NewGenericMatch(
registry map[string]games.GameRules,
) func(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
) (runtime.Match, error) {
return func(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
) (runtime.Match, error) {
// The factory stores the registry on each match instance so MatchInit can use it.
return &GenericMatch{Registry: registry}, nil
}
}
// -------------------------
// Helpers
// -------------------------
func indexOfPlayerByID(players []*structs.Player, userID string) int {
for i, p := range players {
if p.UserID == userID {
return i
}
}
return -1
}
func newEmptyBoard(rows, cols int) *structs.Board {
b := &structs.Board{
Rows: rows,
Cols: cols,
Grid: make([][]string, rows),
}
for r := 0; r < rows; r++ {
b.Grid[r] = make([]string, cols)
}
return b
}
// -------------------------
// Match interface methods
// -------------------------
// MatchInit: create initial state. Expects params to include "game" (string) and optionally "mode".
func (m *GenericMatch) MatchInit(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
params map[string]interface{},
) (interface{}, int, string) {
// ---- 1. game REQUIRED ----
raw, ok := params["game"]
if !ok {
logger.Error("MatchInit ERROR: missing param 'game'")
return nil, 0, ""
}
gameName, ok := raw.(string)
if !ok || gameName == "" {
logger.Error("MatchInit ERROR: invalid 'game' param")
return nil, 0, ""
}
// ---- 2. config lookup ----
cfg, found := games.GameConfig[gameName]
if !found {
logger.Error("MatchInit ERROR: game '%s' not in GameConfig", gameName)
return nil, 0, ""
}
// ---- 3. rules lookup from registry (factory-provided) ----
var rules games.GameRules
if m.Registry != nil {
if r, ok := m.Registry[gameName]; ok {
rules = r
}
}
if rules == nil {
// no rules — abort match creation
logger.Error("MatchInit ERROR: no rules registered for game '%s'", gameName)
return nil, 0, ""
}
// ---- 4. mode (optional) ----
mode := "default"
if md, ok := params["mode"].(string); ok && md != "" {
mode = md
}
// ---- 5. build match instance fields ----
m.GameName = gameName
m.Mode = mode
m.Config = cfg
m.Rules = rules
// ---- 6. create initial state (board from config) ----
state := &structs.MatchState{
Players: []*structs.Player{},
Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols),
Turn: 0,
Winner: -1,
GameOver: false,
}
label := fmt.Sprintf("%s:%s", m.GameName, m.Mode)
logger.Info("MatchInit OK — game=%s mode=%s players=%d board=%dx%d", m.GameName, m.Mode, cfg.Players, cfg.Board.Rows, cfg.Board.Cols)
// Tick rate 5 (200ms) is a sensible default; can be tuned per game.
return state, 5, label
}
// MatchJoinAttempt: basic capacity check using config.Players
func (m *GenericMatch) MatchJoinAttempt(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presence runtime.Presence,
metadata map[string]string,
) (interface{}, bool, string) {
s := state.(*structs.MatchState)
if m.Config.Players <= 0 {
// defensive: require init to have populated config
logger.Error("MatchJoinAttempt ERROR: match config not initialized")
return s, false, "server error"
}
if len(s.Players) >= m.Config.Players {
return s, false, "match full"
}
return s, true, ""
}
// MatchJoin: add players, fetch usernames, assign indices; when full, call rules.AssignPlayerSymbols
func (m *GenericMatch) MatchJoin(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presences []runtime.Presence,
) interface{} {
s := state.(*structs.MatchState)
for _, p := range presences {
userID := p.GetUserId()
if indexOfPlayerByID(s.Players, userID) != -1 {
continue
}
username := ""
if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil {
username = acc.GetUser().GetUsername()
}
s.Players = append(s.Players, &structs.Player{
UserID: userID,
Username: username,
Index: len(s.Players),
Metadata: map[string]string{},
})
}
logger.Info("MatchJoin: now %d players (need %d)", len(s.Players), m.Config.Players)
if m.Rules != nil && len(s.Players) == m.Config.Players {
// Assign player symbols/colors/etc. Pass structs.Player directly.
m.Rules.AssignPlayerSymbols(s.Players)
// Broadcast initial state
if data, err := json.Marshal(s); err == nil {
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (initial state) failed: %v", err)
}
} else {
logger.Error("Failed to marshal initial state: %v", err)
}
}
return s
}
// MatchLeave: mark forfeit and call game.ForfeitWinner
func (m *GenericMatch) MatchLeave(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
presences []runtime.Presence,
) interface{} {
s := state.(*structs.MatchState)
if s.GameOver {
return s
}
// determine leaving player index from presence list
leaverIdx := -1
if len(presences) > 0 {
leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId())
}
if m.Rules != nil {
winner := m.Rules.ForfeitWinner(s, leaverIdx)
s.Winner = winner
s.GameOver = true
} else {
// fallback: end match as forfeit
s.GameOver = true
s.Winner = -1
}
// broadcast final state
if data, err := json.Marshal(s); err == nil {
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (forfeit) failed: %v", err)
}
} else {
logger.Error("Failed to marshal forfeit state: %v", err)
}
return s
}
// MatchLoop: handle incoming move messages, delegate to the GameRules implementation
func (m *GenericMatch) MatchLoop(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
messages []runtime.MatchData,
) interface{} {
s := state.(*structs.MatchState)
if s.GameOver {
return s
}
changed := false
if m.Rules == nil {
logger.Warn("MatchLoop: no rules present for game '%s' -- ignoring messages", m.GameName)
return s
}
for _, msg := range messages {
if msg.GetOpCode() != OpMove {
continue
}
var payload games.MovePayload
if err := json.Unmarshal(msg.GetData(), &payload); err != nil {
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
continue
}
playerID := msg.GetUserId()
playerIdx := indexOfPlayerByID(s.Players, playerID)
if playerIdx == -1 {
logger.Warn("Move rejected: unknown player %s", playerID)
continue
}
// Turn enforcement — keep this here for turn-based games. If you want per-game control,
// move this check into the game's ApplyMove implementation or toggle via config.
if playerIdx != s.Turn {
logger.Warn("Move rejected: not player's turn (idx=%d turn=%d)", playerIdx, s.Turn)
continue
}
// Delegate to rules.ApplyMove which returns (changed, gameOver, winnerIndex)
stateChanged, gameOver, winnerIdx := m.Rules.ApplyMove(s, playerIdx, payload)
if stateChanged {
changed = true
}
if gameOver {
s.GameOver = true
s.Winner = winnerIdx
} else {
if len(s.Players) > 0 {
s.Turn = (s.Turn + 1) % len(s.Players)
}
}
}
// Optional: handle leaderboard logic here if needed
if changed {
if data, err := json.Marshal(s); err == nil {
if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
}
} else {
logger.Error("Failed to marshal state: %v", err)
}
}
return s
}
// MatchTerminate
func (m *GenericMatch) MatchTerminate(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
graceSeconds int,
) interface{} {
logger.Info("MatchTerminate: grace=%d", graceSeconds)
return state
}
// MatchSignal
func (m *GenericMatch) MatchSignal(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
dispatcher runtime.MatchDispatcher,
tick int64,
state interface{},
data string,
) (interface{}, string) {
logger.Debug("MatchSignal: %s", data)
return state, ""
}

View File

@@ -0,0 +1,101 @@
package modules
import (
"context"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
)
type MatchmakingTicket struct {
Game string `json:"game"` // e.g. "tictactoe", "battleship"
Mode string `json:"mode"` // e.g. "classic", "ranked", "blitz"
}
// --------------------------------------------------
// GENERIC MATCHMAKER — Supports ALL Games & Modes
// --------------------------------------------------
func MatchmakerMatched(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
entries []runtime.MatchmakerEntry,
) (string, error) {
if len(entries) == 0 {
return "", nil
}
// Extract the first player's desired properties
props0 := entries[0].GetProperties()
game, okGame := props0["game"].(string)
mode, okMode := props0["mode"].(string)
if !okGame || !okMode {
logger.Error("MatchmakerMatched: Missing 'game' or 'mode' properties.")
return "", runtime.NewError("missing matchmaking properties", 3)
}
// Ensure ALL players match game + mode
for _, e := range entries {
p := e.GetProperties()
g, okG := p["game"].(string)
m, okM := p["mode"].(string)
if !okG || !okM || g != game || m != mode {
logger.Warn("MatchmakerMatched: Player properties do not match — retrying matchmaking.")
return "", nil
}
}
// Create the correct authoritative match handler.
// This depends on how "game" was registered in main.go.
// Example: initializer.RegisterMatch("tictactoe", NewGenericMatch(TicTacToeRules))
matchParams := map[string]interface{}{
"game": game,
"mode": mode,
}
matchID, err := nk.MatchCreate(ctx, "generic", matchParams)
if err != nil {
logger.Error("MatchmakerMatched: MatchCreate failed: %v", err)
return "", runtime.NewError("failed to create match", 13)
}
logger.Info("✔ Match created game=%s mode=%s id=%s", game, mode, matchID)
return matchID, nil
}
// --------------------------------------------------
// RPC: Leave matchmaking (generic cancel API)
// --------------------------------------------------
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)
}
// Client removes ticket locally — server doesn't need to do anything
logger.Info("✔ Player left matchmaking: ticket=%s", input.Ticket)
return "{}", nil
}

51
plugins/structs/board.go Normal file
View File

@@ -0,0 +1,51 @@
package structs
// Board is a generic 2D grid for turn-based games.
// Cell data is stored as strings, but can represent anything (piece, move, state).
type Board struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
Grid [][]string `json:"grid"`
}
// NewBoard creates a grid of empty strings.
func NewBoard(rows, cols int) *Board {
b := &Board{
Rows: rows,
Cols: cols,
Grid: make([][]string, rows),
}
for r := 0; r < rows; r++ {
b.Grid[r] = make([]string, cols)
}
return b
}
func (b *Board) InBounds(row, col int) bool {
return row >= 0 && row < b.Rows && col >= 0 && col < b.Cols
}
func (b *Board) Get(row, col int) string {
return b.Grid[row][col]
}
func (b *Board) Set(row, col int, value string) {
b.Grid[row][col] = value
}
func (b *Board) IsEmpty(row, col int) bool {
return b.Grid[row][col] == ""
}
func (b *Board) Full() bool {
for r := 0; r < b.Rows; r++ {
for c := 0; c < b.Cols; c++ {
if b.Grid[r][c] == "" {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,10 @@
package structs
// MatchState holds the full game session state.
type MatchState struct {
Players []*Player `json:"players"`
Board *Board `json:"board"`
Turn int `json:"turn"` // index in Players[]
Winner int `json:"winner"` // -1 = none, >=0 = winner index
GameOver bool `json:"game_over"` // true when the match ends
}

19
plugins/structs/player.go Normal file
View File

@@ -0,0 +1,19 @@
package structs
// Player represents a participant in the match.
type Player struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Index int `json:"index"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// NewPlayer creates a new player object.
func NewPlayer(userID, username string, index int) *Player {
return &Player{
UserID: userID,
Username: username,
Index: index,
Metadata: make(map[string]string),
}
}