Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c31c489c7 | |||
| 3eadb49a72 | |||
| 3c81a8bf29 | |||
| d9c3ecb252 | |||
| eeb0a8175f | |||
| 70669fc856 | |||
| 1752bfaed4 | |||
| 74e75de577 | |||
| 7983be56e4 | |||
| e9eb37f665 | |||
| 7a7c6adc47 | |||
| ae5628f370 | |||
| e8e2419537 | |||
| 4ee6027612 | |||
| 389e77e2ae | |||
| 7ce860db96 | |||
| 92f307d33d | |||
| 6e7d2d9f14 | |||
| c91dba475c | |||
| 04d988c584 | |||
| 27abc56a00 | |||
| 333b48ad60 | |||
| 4dee0bfb0a | |||
| 1e91825808 | |||
| c5cb1047ae | |||
| 18f9eed71d | |||
| 02de328bcd | |||
| 31f1a66fed | |||
| 5065145c6f |
69
.drone.yml
69
.drone.yml
@@ -17,7 +17,7 @@ volumes:
|
||||
|
||||
steps:
|
||||
# -----------------------------------------------------
|
||||
# 1. Fetch latest Git tag
|
||||
# 1. Fetch latest Tags
|
||||
# -----------------------------------------------------
|
||||
- name: fetch-tags
|
||||
image: docker:24
|
||||
@@ -28,34 +28,43 @@ steps:
|
||||
- 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: $LATEST_TAG"
|
||||
echo "Latest Git tag fetched: $LATEST_TAG"
|
||||
|
||||
# Save to file for downstream steps
|
||||
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "❌ No git tags found. Cannot continue."
|
||||
|
||||
# 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 if remote image already exists
|
||||
# 2. Check existing Nakama Docker image
|
||||
# -----------------------------------------------------
|
||||
- name: check-remote-image
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
REGISTRY_HOST:
|
||||
from_secret: REGISTRY_HOST
|
||||
commands:
|
||||
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||
- echo "Checking if $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG exists..."
|
||||
|
||||
- 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 pull $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG > /dev/null 2>&1; then
|
||||
echo "✅ Image already exists: $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG"
|
||||
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 "⚙️ Image does not exist. Will build."
|
||||
echo "⚙️ Docker image lila-games/nakama-server:$IMAGE_TAG not found — proceeding to build..."
|
||||
fi
|
||||
|
||||
# -----------------------------------------------------
|
||||
@@ -68,28 +77,19 @@ steps:
|
||||
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:$IMAGE_TAG"
|
||||
# Enable buildx
|
||||
- docker buildx create --use
|
||||
|
||||
# Build for ARM64
|
||||
- echo "🔨 Building Nakama image lila-games/nakama-server:latest"
|
||||
- |
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
--network=host \
|
||||
-t lila-games/nakama-server:$IMAGE_TAG \
|
||||
-t lila-games/nakama-server:latest \
|
||||
--push \
|
||||
/drone/src
|
||||
docker build --network=host \
|
||||
-t lila-games/nakama-server:$IMAGE_TAG \
|
||||
-t lila-games/nakama-server:latest \
|
||||
/drone/src
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 4. Push Nakama image to registry
|
||||
# 4. Push Nakama Image
|
||||
# -----------------------------------------------------
|
||||
- name: push-image
|
||||
image: docker:24
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
environment:
|
||||
REGISTRY_HOST:
|
||||
from_secret: REGISTRY_HOST
|
||||
@@ -97,15 +97,20 @@ steps:
|
||||
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..."
|
||||
|
||||
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
|
||||
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
|
||||
- echo "🏷️ Tagging images..."
|
||||
- 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 images..."
|
||||
- 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
|
||||
|
||||
# -----------------------------------------------------
|
||||
@@ -148,9 +153,7 @@ steps:
|
||||
--add-host private-pi:192.168.1.111 \
|
||||
-e DB_ADDR="$DB_ADDR" \
|
||||
-e SERVER_KEY="$SERVER_KEY" \
|
||||
-v /mnt/omnissiah-vault/data/nakama/modules:/nakama/data/modules \
|
||||
-v /mnt/omnissiah-vault/data/nakama/storage:/nakama/data \
|
||||
$REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
|
||||
lila-games/nakama-server:latest
|
||||
|
||||
# -----------------------------------------------------
|
||||
# Pipeline trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -----------------------------------------------------
|
||||
# 1. Build the Nakama plugin
|
||||
# -----------------------------------------------------
|
||||
FROM golang:1.22 AS plugin_builder
|
||||
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
@@ -25,10 +25,11 @@ RUN mkdir -p build && \
|
||||
# -----------------------------------------------------
|
||||
# 2. Build final Nakama image
|
||||
# -----------------------------------------------------
|
||||
FROM heroiclabs/nakama:3.21.0 AS nakama
|
||||
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\" --http.cors=allow_origin:https://games.aetoskia.com"
|
||||
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\""
|
||||
|
||||
403
README.md
403
README.md
@@ -1,287 +1,272 @@
|
||||
# ✅ Project Status Report
|
||||
# tic-tac-toe — Authoritative Multiplayer Game Server (Nakama + Go)
|
||||
|
||||
## Multiplayer Tic-Tac-Toe Platform
|
||||
|
||||
**To:** CTO & Technical Interview Panel
|
||||
|
||||
**Date:** November 28, 2025
|
||||
|
||||
**Version:** v0.2.0
|
||||
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, authoritative gameplay, leaderboards, and a functional UI — 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** | ✅ Completed |
|
||||
| **Game state validation & turn enforcement** | ✅ Completed |
|
||||
| **Leaderboard system** | ✅ Completed |
|
||||
| **UI Game Client** | 🟡 Partially Implemented |
|
||||
| Google Cloud deployment | 🟡 Not Started |
|
||||
|
||||
✅ **Backend is fully authoritative and complete**
|
||||
🟡 **UI functional but missing polish, UX, and failure handling**
|
||||
* **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
|
||||
* **Frontend:** React + Vite + Nakama JS
|
||||
* **Database:** PostgreSQL 14
|
||||
* **Transport:** WebSockets (real-time)
|
||||
* **Authentication:** Device-ID based auth → JWT session returned
|
||||
* **State Management:** Fully server-authoritative
|
||||
* **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
|
||||
|
||||
### Backend
|
||||
**Backend**
|
||||
|
||||
* Device authentication, auto-account creation
|
||||
* JWT returned and used for RT 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
|
||||
|
||||
### UI
|
||||
**Cloud/Infra**
|
||||
|
||||
* Generates a device UUID and authenticates via `client.authenticateDevice()`
|
||||
* Stores and manages session state in React context
|
||||
* GCP-ready
|
||||
* ARM homelab deployments
|
||||
* Private registry support
|
||||
|
||||
---
|
||||
|
||||
## **5. Game Server Logic (Go)**
|
||||
## 📦 Repository Structure
|
||||
|
||||
Significant enhancements made:
|
||||
|
||||
### **✔ Initial State Broadcast**
|
||||
|
||||
* When the second player joins, the server immediately sends the full authoritative state.
|
||||
|
||||
### **✔ Complete Turn + Move Validation**
|
||||
|
||||
Rejects:
|
||||
|
||||
* out-of-bounds moves
|
||||
* occupied cells
|
||||
* wrong player's turn
|
||||
* invalid payloads
|
||||
|
||||
### **✔ Forfeit Handling**
|
||||
|
||||
* When a user disconnects or leaves, match ends in `forfeit`
|
||||
* Final state broadcast to remaining player
|
||||
|
||||
### **✔ Authoritative State Updates**
|
||||
|
||||
* Only broadcasts when state actually changes
|
||||
* Robust structured logging
|
||||
|
||||
Result:
|
||||
**Absolute server authority, zero trust in client.**
|
||||
```
|
||||
tic-tac-toe/
|
||||
│── plugins/
|
||||
│ ├── main.go
|
||||
│ ├── match.go
|
||||
│ └── matchmaking.go
|
||||
│
|
||||
│── Dockerfile
|
||||
│── go.mod
|
||||
│── go.sum
|
||||
│── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **6. Real-Time Networking**
|
||||
## 🔌 Registered Server Components
|
||||
|
||||
Communication validated end-to-end:
|
||||
### 📌 Match Handler
|
||||
|
||||
* `match_create`
|
||||
* `match_join`
|
||||
* `matchmaker_add` / `matchmaker_matched`
|
||||
* `match_data_send` for moves (OpCode 1)
|
||||
* Server broadcasts state (OpCode 2)
|
||||
Name: **`tictactoe`**
|
||||
|
||||
Python simulators and UI both confirm:
|
||||
Handles:
|
||||
|
||||
* move ordering
|
||||
* correct enforcement of turn rules
|
||||
* correct state sync
|
||||
* stable WebSocket behavior
|
||||
* 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 System (Go + UI)**
|
||||
## 🎮 Gameplay Protocol
|
||||
|
||||
### **Backend (Go)**
|
||||
### OpCodes
|
||||
|
||||
* Implements `MatchmakerMatched` hook
|
||||
* Ensures both players:
|
||||
|
||||
* have valid `mode`
|
||||
* match modes
|
||||
* exactly two players
|
||||
* Creates authoritative matches server-side
|
||||
* RPC `leave_matchmaking` added
|
||||
|
||||
### **UI**
|
||||
|
||||
* Integrates matchmaking: mode selection → queue → ticket → matched → auto-join
|
||||
* Uses Nakama JS `socket.addMatchmaker()`
|
||||
|
||||
**Status:** Fully functional end-to-end
|
||||
| OpCode | Direction | Meaning |
|
||||
| ------ | --------------- | ----------------------------- |
|
||||
| **1** | Client → Server | Submit move `{x, y}` |
|
||||
| **2** | Server → Client | Full authoritative game state |
|
||||
|
||||
---
|
||||
|
||||
## **8. Leaderboard System (Go + UI)**
|
||||
## 🧠 Authoritative Flow Diagram
|
||||
|
||||
### **Backend (Go)**
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C1 as Client 1
|
||||
participant C2 as Client 2
|
||||
participant S as Nakama + Go Plugin
|
||||
|
||||
* Leaderboard auto-created on startup
|
||||
* On win:
|
||||
C1->>S: Matchmaker Join
|
||||
C2->>S: Matchmaker Join
|
||||
S->>S: MatchmakerMatched()
|
||||
S-->>C1: Matched
|
||||
S-->>C2: Matched
|
||||
|
||||
* winner identified
|
||||
* username resolved
|
||||
* score incremented (+1 win)
|
||||
* metadata logged
|
||||
|
||||
### **UI**
|
||||
|
||||
* Implements leaderboard view using `client.listLeaderboardRecords()`
|
||||
* Read-only UI display works
|
||||
|
||||
### **Remaining**
|
||||
|
||||
* UI sorting, layout, styling
|
||||
* No leaderboard write actions needed from UI
|
||||
C1->>S: OpCode 1 (Move)
|
||||
S->>S: Validate Move
|
||||
S-->>C1: OpCode 2 (Updated State)
|
||||
S-->>C2: OpCode 2 (Updated State)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **9. UI Implementation Status (React + Vite)**
|
||||
## ⚙️ Build & Development
|
||||
|
||||
### **What Is Implemented**
|
||||
### Build Go Plugin
|
||||
|
||||
- ✔ Authentication flow (device auth)
|
||||
- ✔ WebSocket session handling
|
||||
- ✔ Matchmaking (classic/blitz modes)
|
||||
- ✔ Automatic match join
|
||||
- ✔ Move sending (OpCode 1)
|
||||
- ✔ State updates (OpCode 2)
|
||||
- ✔ Board rendering and interactive cells
|
||||
- ✔ End-of-game messaging
|
||||
- ✔ Leaderboard display
|
||||
```
|
||||
CGO_ENABLED=1 go build \
|
||||
--trimpath \
|
||||
--buildmode=plugin \
|
||||
-o build/main.so \
|
||||
./plugins
|
||||
```
|
||||
|
||||
### **Partially Implemented**
|
||||
### Run Nakama Locally
|
||||
|
||||
- 🟡 Match mode UI selection wired but not visually emphasized
|
||||
- 🟡 Context handles all RT states but missing error handling
|
||||
|
||||
### **Not Implemented / Missing**
|
||||
|
||||
- 🔴 Reconnect flow (UI does not recover session after WS drop)
|
||||
- 🔴 No error UI for: matchmaking failure, move rejection, disconnects
|
||||
- 🔴 No UI for leaving match or returning to lobby
|
||||
- 🔴 No rematch button / flow
|
||||
- 🔴 No transitions, animations, or mobile layout
|
||||
- 🔴 No global app shell (Routing, Home Screen, etc.)
|
||||
|
||||
**Summary:** UI is *functional* and capable of playing full authoritative games, but lacks UX polish and failure
|
||||
handling.
|
||||
```
|
||||
nakama migrate up --database.address "$DB_ADDR"
|
||||
nakama \
|
||||
--database.address "$DB_ADDR" \
|
||||
--socket.server_key="$SERVER_KEY"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **10. Testing & Validation**
|
||||
## 🐳 Docker Build (Multi-Stage)
|
||||
|
||||
### Backend
|
||||
```dockerfile
|
||||
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
|
||||
|
||||
* Extensive scenario tests: draws, wins, illegal moves, disconnects
|
||||
* Matchmaking simulation across N clients
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 go build --buildmode=plugin -o build/main.so ./plugins
|
||||
|
||||
### UI
|
||||
|
||||
* Verified:
|
||||
|
||||
* matchmaking
|
||||
* game correctness
|
||||
* leaderboard retrieval
|
||||
* state sync
|
||||
* Missing:
|
||||
|
||||
* stress testing
|
||||
* reconnection scenarios
|
||||
* mobile layout testing
|
||||
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**
|
||||
## 🤖 CI/CD — Drone Pipeline
|
||||
|
||||
Planned architecture remains:
|
||||
Drone performs:
|
||||
|
||||
| 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) |
|
||||
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
|
||||
|
||||
Estimated setup time: **6–8 hours**
|
||||
Full pipeline included in repository (`.drone.yml`).
|
||||
|
||||
---
|
||||
|
||||
## **12. Risks & Considerations**
|
||||
## 🌐 Traefik Routing
|
||||
|
||||
| Risk | Mitigation |
|
||||
|--------------------------------|--------------------------------|
|
||||
| UI lacks error/reconnect logic | Add retry + reconnection flows |
|
||||
| No rematch or lobby UX | Add match lifecycle UI |
|
||||
| No mobile layout | Add responsive CSS |
|
||||
| Cloud deployment pending | Prioritize after UI polish |
|
||||
| Matchmaking UX is minimal | Add feedback, loading states |
|
||||
### HTTPS
|
||||
|
||||
None of these affect core backend stability.
|
||||
```
|
||||
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
|
||||
|
||||
### **UI Tasks**
|
||||
|
||||
1. Add reconnect + error-handling UI
|
||||
2. Create lobby → gameplay → results, flow
|
||||
3. Add rematch capability
|
||||
4. Add responsive + polished UI
|
||||
5. Add loading indicators & animations
|
||||
|
||||
Estimated remaining effort: **6 to 8 hours**
|
||||
| Variable | Description |
|
||||
| --------------- | ---------------------------- |
|
||||
| `DB_ADDR` | PostgreSQL connection string |
|
||||
| `SERVER_KEY` | Nakama server key |
|
||||
| `REGISTRY_HOST` | Private registry |
|
||||
|
||||
---
|
||||
|
||||
## **Executive Summary**
|
||||
## 📊 Leaderboards
|
||||
|
||||
### Issues faced
|
||||
Several unexpected integration challenges during UI setup contributed to the additional day of work. These included:
|
||||
- Aligning the UI’s matchmaking flow with the new authoritative Go-based matchmaking logic.
|
||||
- Handling Nakama JS WebSocket behaviors, especially around session timing, matchmaker ticket handling, and match join events.
|
||||
- Ensuring OpCode handling and server-produced state updates matched the server’s authoritative model.
|
||||
- Resolving environment-related issues (Vite dev server, Node version mismatches, and WebSocket URL configuration).
|
||||
- Debugging cross-origin and connection-reset issues during early WebSocket initialization.
|
||||
* Created during server start
|
||||
* Score: `+1` on win
|
||||
* Metadata logged (mode, player IDs)
|
||||
|
||||
These challenges required deeper synchronization between the backend and frontend layers, resulting in an additional **+1 day** of engineering time.
|
||||
---
|
||||
|
||||
### Project Progress
|
||||
The system now features a fully authoritative backend with matchmaking, gameplay logic, and leaderboards implemented
|
||||
completely in Go. The UI is functional and integrates correctly with all backend systems, supporting end-to-end
|
||||
matchmaking and gameplay.
|
||||
## 🧪 Testing
|
||||
|
||||
Key remaining work involves UI polish, recovery/error handling, and deployment setup. No major architectural risks
|
||||
remain.
|
||||
* Win/loss/draw simulation
|
||||
* Invalid move rejection
|
||||
* Disconnect → forfeit
|
||||
* Load testing matchmaking
|
||||
|
||||
**The project is now fully playable, technically solid, and ready for final UI enhancements and cloud deployment.**
|
||||
---
|
||||
|
||||
## 📈 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.
|
||||
|
||||
---
|
||||
|
||||
4
go.mod
4
go.mod
@@ -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
|
||||
|
||||
183
plugins/games/battleship.go
Normal file
183
plugins/games/battleship.go
Normal 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
23
plugins/games/config.go
Normal 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
27
plugins/games/rules.go
Normal 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
|
||||
}
|
||||
149
plugins/games/tic_tac_toe.go
Normal file
149
plugins/games/tic_tac_toe.go
Normal 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 ""
|
||||
}
|
||||
108
plugins/main.go
108
plugins/main.go
@@ -5,61 +5,83 @@ import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/heroiclabs/nakama-common/runtime"
|
||||
|
||||
// Project modules
|
||||
"localrepo/plugins/modules"
|
||||
"localrepo/plugins/games"
|
||||
)
|
||||
|
||||
// Example RPC
|
||||
func HelloWorld(
|
||||
func InitModule(
|
||||
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,
|
||||
initializer runtime.Initializer,
|
||||
) error {
|
||||
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
|
||||
logger.Error("Failed to register RPC: %v", err)
|
||||
return err
|
||||
}
|
||||
if err := initializer.RegisterMatch("tictactoe", NewMatch); err != nil {
|
||||
logger.Error("Failed to register RPC: %v", 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)
|
||||
|
||||
//--------------------------------------------------------
|
||||
// 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
|
||||
}
|
||||
|
||||
err := nk.LeaderboardCreate(
|
||||
ctx,
|
||||
"tictactoe", // id
|
||||
true, // authoritative
|
||||
"desc", // sortOrder
|
||||
"incr", // operator
|
||||
"", // resetSchedule
|
||||
map[string]interface{}{}, // metadata
|
||||
)
|
||||
//--------------------------------------------------------
|
||||
// 2. Register Matchmaker Handler
|
||||
//--------------------------------------------------------
|
||||
if err := initializer.RegisterMatchmakerMatched(modules.MatchmakerMatched); err != nil {
|
||||
logger.Error("Failed to register MatchmakerMatched: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil && err.Error() != "Leaderboard ID already exists" {
|
||||
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("Leaderboard tictactoe ready")
|
||||
logger.Info("Go module loaded successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
378
plugins/match.go
378
plugins/match.go
@@ -1,378 +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))
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ---- 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")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.GetOpCode() != OpMove {
|
||||
logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode())
|
||||
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 from %s: %v", msg.GetUserId(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
playerID := msg.GetUserId()
|
||||
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 {
|
||||
logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn)
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if s.Board[move.Row][move.Col] != "" {
|
||||
logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col)
|
||||
continue
|
||||
}
|
||||
|
||||
symbols := []string{"X", "O"}
|
||||
if playerIdx < 0 || playerIdx >= len(symbols) {
|
||||
logger.Warn("Move rejected: invalid player index %d", playerIdx)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
s.Winner = winner
|
||||
s.GameOver = true
|
||||
logger.Info("Game over! Winner: %s", winner)
|
||||
} else if fullBoard(s.Board) {
|
||||
s.Winner = "draw"
|
||||
s.GameOver = true
|
||||
logger.Info("Game over! Draw")
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If anything changed (or periodically if you want), broadcast updated state to everyone
|
||||
if changed {
|
||||
stateJSON, err := json.Marshal(s)
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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 don’t 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
|
||||
}
|
||||
389
plugins/modules/match.go
Normal file
389
plugins/modules/match.go
Normal 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, ""
|
||||
}
|
||||
101
plugins/modules/matchmaking.go
Normal file
101
plugins/modules/matchmaking.go
Normal 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
51
plugins/structs/board.go
Normal 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
|
||||
}
|
||||
10
plugins/structs/match_state.go
Normal file
10
plugins/structs/match_state.go
Normal 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
19
plugins/structs/player.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user