Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae5628f370 | |||
| e8e2419537 | |||
| 4ee6027612 | |||
| 389e77e2ae | |||
| 7ce860db96 | |||
| 92f307d33d | |||
| 6e7d2d9f14 | |||
| c91dba475c | |||
| 04d988c584 | |||
| 27abc56a00 | |||
| 333b48ad60 | |||
| 4dee0bfb0a | |||
| 1e91825808 | |||
| c5cb1047ae | |||
| 18f9eed71d | |||
| 02de328bcd | |||
| 31f1a66fed | |||
| 5065145c6f | |||
| 8e0475c8a7 | |||
| 1359a75214 | |||
| 6786547950 | |||
| 17a2caea49 | |||
| 8ff199ca10 | |||
| 62c1f3920b | |||
| 73e3f0a7ac | |||
| 21f6698b89 | |||
| 06904143d6 | |||
| ba05aa7c76 | |||
| bfcf977e13 | |||
| 6eca090046 | |||
| 07cb519f55 | |||
| 840d7701ca | |||
| f1f40f5799 | |||
| 0d8d3785fc | |||
| 65b5ef4660 | |||
| 95381c2a56 | |||
| ead7ad2c35 | |||
| 4a833dc258 | |||
| 37c090cf64 | |||
| 7bcdc76594 | |||
| 087616a67e | |||
| 10058710fb | |||
| eb35ccd180 | |||
| bd376123b3 | |||
| ea1a70b212 | |||
| 908eebefdd | |||
| 0fb448dd45 | |||
| fa4d4d00be | |||
| 22993d6d37 | |||
| de4bfb6c07 | |||
| d260c9a1ef | |||
| 1b4e7a5ee0 | |||
| cb23d3c516 | |||
| 37b20c6c36 |
163
.drone.yml
Normal file
163
.drone.yml
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
.venv
|
||||
/vendor/
|
||||
/build/
|
||||
.env
|
||||
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal 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\""
|
||||
249
README.md
249
README.md
@@ -1,23 +1,27 @@
|
||||
# ✅ Project Status Report — Multiplayer Tic-Tac-Toe Platform
|
||||
# ✅ Project Status Report
|
||||
|
||||
## Multiplayer Tic-Tac-Toe Platform
|
||||
|
||||
**To:** CTO & Technical Interview Panel
|
||||
|
||||
**Date:** November 25, 2025
|
||||
**Date:** November 28, 2025
|
||||
|
||||
**Version:** v0.0.1
|
||||
**Version:** v0.2.0
|
||||
|
||||
---
|
||||
|
||||
## **1. Objective**
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## **2. Current Completion Summary**
|
||||
|
||||
| Requirement | Status |
|
||||
| ---------------------------------------- | -------------------------------- |
|
||||
|----------------------------------------------|--------------------------|
|
||||
| Install Nakama + PostgreSQL | ✅ Completed |
|
||||
| Custom Go server plugins | ✅ Completed |
|
||||
| Server-authoritative Tic-Tac-Toe | ✅ Completed |
|
||||
@@ -25,13 +29,14 @@ Design and implement a lightweight, scalable, server-authoritative multiplayer g
|
||||
| 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 |
|
||||
| **Matchmaking queue support** | ✅ Completed |
|
||||
| **Game state validation & turn enforcement** | ✅ Completed |
|
||||
| **Leaderboard system** | ✅ Completed |
|
||||
| **UI Game Client** | 🟡 Partially Implemented |
|
||||
| Google Cloud deployment | 🟡 Not Started |
|
||||
|
||||
✅ **Core backend functionality is complete and stable**
|
||||
✅ **Backend is fully authoritative and complete**
|
||||
🟡 **UI functional but missing polish, UX, and failure handling**
|
||||
|
||||
---
|
||||
|
||||
@@ -39,113 +44,188 @@ Design and implement a lightweight, scalable, server-authoritative multiplayer g
|
||||
|
||||
* **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:** Server-authoritative, deterministic
|
||||
* **Protocol:** Nakama RT JSON envelopes
|
||||
* **State Management:** Fully server-authoritative
|
||||
* **Build & Deployment:** Docker-based
|
||||
|
||||
---
|
||||
|
||||
## **4. Authentication**
|
||||
|
||||
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
|
||||
* Device authentication, auto-account creation
|
||||
* JWT returned and used for RT connections
|
||||
|
||||
### UI
|
||||
|
||||
* Generates a device UUID and authenticates via `client.authenticateDevice()`
|
||||
* Stores and manages session state in React context
|
||||
|
||||
---
|
||||
|
||||
## **5. Game Server Logic**
|
||||
## **5. Game Server Logic (Go)**
|
||||
|
||||
Implemented as Go match module:
|
||||
Significant enhancements made:
|
||||
|
||||
* Turn-based validation
|
||||
* Board occupancy checks
|
||||
* Win/draw/forfeit detection
|
||||
* Automatic broadcast of updated state
|
||||
* Graceful match termination
|
||||
* Prevents cheating & client-side manipulation
|
||||
### **✔ 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:
|
||||
✅ Entire game lifecycle enforced server-side.
|
||||
**Absolute server authority, zero trust in client.**
|
||||
|
||||
---
|
||||
|
||||
## **6. Real-Time Networking**
|
||||
|
||||
Clients communicate via:
|
||||
Communication validated end-to-end:
|
||||
|
||||
* `match_create`
|
||||
* `match_join`
|
||||
* `match_data_send` (OpCode 1) → moves
|
||||
* Broadcast state updates (OpCode 2)
|
||||
* `matchmaker_add` / `matchmaker_matched`
|
||||
* `match_data_send` for moves (OpCode 1)
|
||||
* Server broadcasts state (OpCode 2)
|
||||
|
||||
Python WebSocket simulation confirms:
|
||||
✅ Move sequencing
|
||||
✅ Session isolation
|
||||
✅ Messaging reliability
|
||||
✅ Auto-cleanup on disconnect
|
||||
Python simulators and UI both confirm:
|
||||
|
||||
* move ordering
|
||||
* correct enforcement of turn rules
|
||||
* correct state sync
|
||||
* stable WebSocket behavior
|
||||
|
||||
---
|
||||
|
||||
## **7. Matchmaking**
|
||||
## **7. Matchmaking System (Go + UI)**
|
||||
|
||||
**NOT STARTED**
|
||||
### **Backend (Go)**
|
||||
|
||||
* 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
|
||||
|
||||
---
|
||||
|
||||
## **8. Testing & Validation**
|
||||
## **8. Leaderboard System (Go + UI)**
|
||||
|
||||
Performed using:
|
||||
### **Backend (Go)**
|
||||
|
||||
* Automated two-client WebSocket game flow - Happy path
|
||||
* Leaderboard auto-created on startup
|
||||
* On win:
|
||||
|
||||
**PENDING STRESS AND EDGE CASE TESTING**
|
||||
* 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
|
||||
|
||||
---
|
||||
|
||||
## **9. UI Status**
|
||||
## **9. UI Implementation Status (React + Vite)**
|
||||
|
||||
Not implemented yet — intentionally deferred.
|
||||
### **What Is Implemented**
|
||||
|
||||
Planned:
|
||||
- ✔ 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
|
||||
|
||||
* Simple browser/mobile client
|
||||
* Display board, turns, win state
|
||||
* WebSocket integration
|
||||
* Leaderboard screen
|
||||
### **Partially Implemented**
|
||||
|
||||
Estimated time: **6–10 hours**
|
||||
- 🟡 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.
|
||||
|
||||
---
|
||||
|
||||
## **10. Leaderboard System**
|
||||
## **10. Testing & Validation**
|
||||
|
||||
Backend-ready but not finalized:
|
||||
### Backend
|
||||
|
||||
✅ Database & Nakama leaderboard APIs available
|
||||
✅ Game result reporting planned
|
||||
🟡 Ranking, ELO, win streak logic pending
|
||||
* Extensive scenario tests: draws, wins, illegal moves, disconnects
|
||||
* Matchmaking simulation across N clients
|
||||
|
||||
Estimated time: **4–6 hours**
|
||||
### UI
|
||||
|
||||
* Verified:
|
||||
|
||||
* matchmaking
|
||||
* game correctness
|
||||
* leaderboard retrieval
|
||||
* state sync
|
||||
* Missing:
|
||||
|
||||
* stress testing
|
||||
* reconnection scenarios
|
||||
* mobile layout testing
|
||||
|
||||
---
|
||||
|
||||
## **11. Google Cloud Deployment (Interview-Scope)**
|
||||
## **11. Google Cloud Deployment**
|
||||
|
||||
Goal: **Simple, affordable, demo-ready deployment**
|
||||
|
||||
### Planned architecture:
|
||||
|
||||
* Highly subjective as of now
|
||||
Planned architecture remains:
|
||||
|
||||
| Component | GCP Service |
|
||||
| ------------- | ----------------------------- |
|
||||
|---------------|-------------------------------|
|
||||
| Nakama server | Compute Engine VM (Docker) |
|
||||
| PostgreSQL | Cloud SQL (shared tier) |
|
||||
| Game UI | Cloud Run or Firebase Hosting |
|
||||
@@ -159,32 +239,49 @@ Estimated setup time: **6–8 hours**
|
||||
## **12. Risks & Considerations**
|
||||
|
||||
| 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 |
|
||||
|--------------------------------|--------------------------------|
|
||||
| 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 |
|
||||
|
||||
None block demonstration or evaluation.
|
||||
None of these affect core backend stability.
|
||||
|
||||
---
|
||||
|
||||
## **13. Next Steps**
|
||||
|
||||
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
|
||||
### **UI Tasks**
|
||||
|
||||
Estimated remaining effort: **1.5–2.5 days**
|
||||
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**
|
||||
|
||||
---
|
||||
|
||||
## **Executive Summary**
|
||||
|
||||
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.
|
||||
### 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.
|
||||
|
||||
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.
|
||||
These challenges required deeper synchronization between the backend and frontend layers, resulting in an additional **+1 day** of engineering time.
|
||||
|
||||
**The project is technically strong, progressing as planned, and positioned for successful final delivery and demonstration.**
|
||||
### 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.
|
||||
|
||||
Key remaining work involves UI polish, recovery/error handling, and deployment setup. No major architectural risks
|
||||
remain.
|
||||
|
||||
**The project is now fully playable, technically solid, and ready for final UI enhancements and cloud deployment.**
|
||||
|
||||
@@ -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
|
||||
|
||||
386
game_flow.py
386
game_flow.py
@@ -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)."""
|
||||
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": custom_id},
|
||||
json={"id": self.custom_id},
|
||||
)
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
return body["token"]
|
||||
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()
|
||||
|
||||
# ---------- WebSocket helpers ----------
|
||||
url = f"{WS}/ws?token={self.token}"
|
||||
self.ws = await websockets.connect(url)
|
||||
|
||||
async def connect(token: str) -> websockets.ClientConnection:
|
||||
url = f"{WS}/ws?token={token}"
|
||||
ws = await websockets.connect(url)
|
||||
return ws
|
||||
|
||||
|
||||
async def listener(ws, label: str):
|
||||
"""Log all messages for a given socket."""
|
||||
# ---------- Listener ----------
|
||||
async def listener(self):
|
||||
"""Continuously receive events + decode match data."""
|
||||
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)
|
||||
raw = await self.ws.recv()
|
||||
msg = json.loads(raw)
|
||||
await self.on_message(msg) # ✅ handoff
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
print(f"[{label}] WebSocket closed cleanly")
|
||||
return
|
||||
print(f"[{self.label}] WebSocket closed gracefully")
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
print(f"[{label}] WebSocket closed with error: {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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
def parse_match_data(md, label: str):
|
||||
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())
|
||||
|
||||
# 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"[{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")
|
||||
return
|
||||
else:
|
||||
print(f"[{self.label}] UNKNOWN OPCODE {op}: {payload}")
|
||||
|
||||
# other opcodes
|
||||
raise RuntimeError(f"[{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 send_move(ws, match_id: str, row: int, col: int):
|
||||
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data."""
|
||||
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}
|
||||
# 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")
|
||||
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||
|
||||
msg = {
|
||||
await self.ws.send(json.dumps({
|
||||
"match_data_send": {
|
||||
"match_id": match_id,
|
||||
"op_code": 1, # OpMove
|
||||
"data": data_b64,
|
||||
"op_code": 1,
|
||||
"data": encoded,
|
||||
}
|
||||
}
|
||||
await ws.send(json.dumps(msg))
|
||||
}))
|
||||
print(f"[{self.label}] Sent move: {payload}")
|
||||
|
||||
|
||||
# ---------- Main flow ----------
|
||||
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 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
|
||||
|
||||
|
||||
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__":
|
||||
|
||||
@@ -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
101
match_making_flow.py
Normal 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))
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/heroiclabs/nakama-common/runtime"
|
||||
)
|
||||
|
||||
// Example RPC
|
||||
func HelloWorld(
|
||||
ctx context.Context,
|
||||
logger runtime.Logger,
|
||||
@@ -34,7 +35,31 @@ func InitModule(
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
err := nk.LeaderboardCreate(
|
||||
ctx,
|
||||
"tictactoe", // id
|
||||
true, // authoritative
|
||||
"desc", // sortOrder
|
||||
"incr", // operator
|
||||
"", // resetSchedule
|
||||
map[string]interface{}{}, // metadata
|
||||
)
|
||||
|
||||
if err != nil && err.Error() != "Leaderboard ID already exists" {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("Leaderboard tictactoe ready")
|
||||
logger.Info("Go module loaded successfully!")
|
||||
return nil
|
||||
}
|
||||
|
||||
102
plugins/match.go
102
plugins/match.go
@@ -106,6 +106,21 @@ func (m *TicTacToeMatch) MatchJoin(
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -128,6 +143,18 @@ func (m *TicTacToeMatch) MatchLeave(
|
||||
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
|
||||
@@ -151,8 +178,11 @@ func (m *TicTacToeMatch) MatchLoop(
|
||||
return s
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.GetOpCode() != OpMove {
|
||||
logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode())
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -162,46 +192,108 @@ func (m *TicTacToeMatch) MatchLoop(
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg.GetData(), &move); err != nil {
|
||||
logger.Warn("Invalid move payload: %v", err)
|
||||
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
// not your 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
|
||||
}
|
||||
s.Board[move.Row][move.Col] = symbols[playerIdx]
|
||||
|
||||
// Apply move
|
||||
s.Board[move.Row][move.Col] = symbols[playerIdx]
|
||||
changed = true
|
||||
logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col)
|
||||
|
||||
// Check win/draw
|
||||
if winner := checkWinner(s.Board); winner != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast updated state to everyone
|
||||
stateJSON, _ := json.Marshal(s)
|
||||
// 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
|
||||
|
||||
86
plugins/matchmaking.go
Normal file
86
plugins/matchmaking.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/heroiclabs/nakama-common/runtime"
|
||||
)
|
||||
|
||||
type MatchmakingTicket struct {
|
||||
UserID string `json:"user_id"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// MatchmakerMatched is triggered automatically when enough players form a match.
|
||||
func MatchmakerMatched(
|
||||
ctx context.Context,
|
||||
logger runtime.Logger,
|
||||
db *sql.DB,
|
||||
nk runtime.NakamaModule,
|
||||
entries []runtime.MatchmakerEntry,
|
||||
) (string, error) {
|
||||
|
||||
if len(entries) != 2 {
|
||||
logger.Warn("MatchmakerMatched triggered with %d players", len(entries))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
propsA := entries[0].GetProperties()
|
||||
propsB := entries[1].GetProperties()
|
||||
validModes := map[string]bool{"classic": true, "blitz": true}
|
||||
|
||||
modeA, okA := propsA["mode"].(string)
|
||||
modeB, okB := propsB["mode"].(string)
|
||||
|
||||
if !okA || !okB || !validModes[modeA] || !validModes[modeB] {
|
||||
logger.Warn("MatchmakerMatched missing mode property — ignoring")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// ✅ If modes 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
|
||||
}
|
||||
Reference in New Issue
Block a user