54 Commits

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

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

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

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

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

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

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

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

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

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

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

163
.drone.yml Normal file
View File

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

3
.gitignore vendored
View File

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

35
Dockerfile Normal file
View File

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

269
README.md
View File

@@ -1,37 +1,42 @@
# ✅ Project Status Report — Multiplayer Tic-Tac-Toe Platform # ✅ Project Status Report
## Multiplayer Tic-Tac-Toe Platform
**To:** CTO & Technical Interview Panel **To:** CTO & Technical Interview Panel
**Date:** November 25, 2025 **Date:** November 28, 2025
**Version:** v0.0.1 **Version:** v0.2.0
--- ---
## **1. Objective** ## **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** ## **2. Current Completion Summary**
| Requirement | Status | | Requirement | Status |
| ---------------------------------------- | -------------------------------- | |----------------------------------------------|--------------------------|
| Install Nakama + PostgreSQL | ✅ Completed | | Install Nakama + PostgreSQL | ✅ Completed |
| Custom Go server plugins | ✅ Completed | | Custom Go server plugins | ✅ Completed |
| Server-authoritative Tic-Tac-Toe | ✅ Completed | | Server-authoritative Tic-Tac-Toe | ✅ Completed |
| Real-time WebSocket communication | ✅ Completed | | Real-time WebSocket communication | ✅ Completed |
| Device-based authentication | ✅ Completed | | Device-based authentication | ✅ Completed |
| JWT-based session management | ✅ Completed | | JWT-based session management | ✅ Completed |
| Match creation & joining | ✅ Completed | | Match creation & joining | ✅ Completed |
| Matchmaking queue support | 🟡 Not Started | | **Matchmaking queue support** | ✅ Completed |
| Game state validation & turn enforcement | 🟡 Not Started | | **Game state validation & turn enforcement** | ✅ Completed |
| Leaderboard/tracking foundation | 🟡 Not Started | | **Leaderboard system** | ✅ Completed |
| UI Game Client | 🟡 Not Started | | **UI Game Client** | 🟡 Partially Implemented |
| Google Cloud deployment | 🟡 Not Started | | 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 * **Backend Framework:** Nakama 3.x
* **Business Logic:** Custom Go runtime module * **Business Logic:** Custom Go runtime module
* **Frontend:** React + Vite + Nakama JS
* **Database:** PostgreSQL 14 * **Database:** PostgreSQL 14
* **Transport:** WebSockets (real-time) * **Transport:** WebSockets (real-time)
* **Authentication:** Device-ID based auth → JWT session returned * **Authentication:** Device-ID based auth → JWT session returned
* **State Management:** Server-authoritative, deterministic * **State Management:** Fully server-authoritative
* **Protocol:** Nakama RT JSON envelopes
* **Build & Deployment:** Docker-based * **Build & Deployment:** Docker-based
--- ---
## **4. Authentication** ## **4. Authentication**
Implemented **Nakama device authentication flow**: ### Backend
1. Client provides device UUID * Device authentication, auto-account creation
2. Nakama validates & creates account if needed * JWT returned and used for RT connections
3. Server responds with **JWT session token**
4. Client uses JWT for all WebSocket 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 ### **✔ Initial State Broadcast**
* Board occupancy checks
* Win/draw/forfeit detection * When the second player joins, the server immediately sends the full authoritative state.
* Automatic broadcast of updated state
* Graceful match termination ### **✔ Complete Turn + Move Validation**
* Prevents cheating & client-side manipulation
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: Result:
✅ Entire game lifecycle enforced server-side. **Absolute server authority, zero trust in client.**
--- ---
## **6. Real-Time Networking** ## **6. Real-Time Networking**
Clients communicate via: Communication validated end-to-end:
* `match_create` * `match_create`
* `match_join` * `match_join`
* `match_data_send` (OpCode 1) → moves * `matchmaker_add` / `matchmaker_matched`
* Broadcast state updates (OpCode 2) * `match_data_send` for moves (OpCode 1)
* Server broadcasts state (OpCode 2)
Python WebSocket simulation confirms: Python simulators and UI both confirm:
✅ Move sequencing
✅ Session isolation * move ordering
✅ Messaging reliability * correct enforcement of turn rules
✅ Auto-cleanup on disconnect * 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 ### **Partially Implemented**
* Display board, turns, win state
* WebSocket integration
* Leaderboard screen
Estimated time: **610 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 * Extensive scenario tests: draws, wins, illegal moves, disconnects
✅ Game result reporting planned * Matchmaking simulation across N clients
🟡 Ranking, ELO, win streak logic pending
Estimated time: **46 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 remains:
### Planned architecture:
* Highly subjective as of now
| Component | GCP Service | | Component | GCP Service |
| ------------- | ----------------------------- | |---------------|-------------------------------|
| Nakama server | Compute Engine VM (Docker) | | Nakama server | Compute Engine VM (Docker) |
| PostgreSQL | Cloud SQL (shared tier) | | PostgreSQL | Cloud SQL (shared tier) |
| Game UI | Cloud Run or Firebase Hosting | | Game UI | Cloud Run or Firebase Hosting |
@@ -158,33 +238,50 @@ Estimated setup time: **68 hours**
## **12. Risks & Considerations** ## **12. Risks & Considerations**
| Risk | Mitigation | | Risk | Mitigation |
| ------------------------ | ------------------------- | |--------------------------------|--------------------------------|
| No UI yet | Prioritized next | | UI lacks error/reconnect logic | Add retry + reconnection flows |
| Only happy path tested | In parallel with UI work | | No rematch or lobby UX | Add match lifecycle UI |
| Matchmaking incomplete | Clear implementation plan | | No mobile layout | Add responsive CSS |
| Leaderboard incomplete | Clear implementation plan | | 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** ## **13. Next Steps**
1. Implement browser/mobile UI ### **UI Tasks**
2. Stress, load and Edge case testing
3. Complete match making, leaderboard scoring
4. Deploy to Google Cloud for public access
5. Record demo video + documentation
Estimated remaining effort: **1.52.5 days** 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** ## **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 UIs 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 servers 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.**

View File

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

View File

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

View File

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

101
match_making_flow.py Normal file
View File

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

View File

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

View File

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

86
plugins/matchmaking.go Normal file
View File

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