21 Commits

Author SHA1 Message Date
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
10 changed files with 503 additions and 98 deletions

160
.drone.yml Normal file
View File

@@ -0,0 +1,160 @@
---
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 Git tag
# -----------------------------------------------------
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag: $LATEST_TAG"
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
if [ -z "$LATEST_TAG" ]; then
echo "❌ No git tags found. Cannot continue."
exit 1
fi
# -----------------------------------------------------
# 2. Check if remote image already exists
# -----------------------------------------------------
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG exists..."
- |
if docker pull $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG > /dev/null 2>&1; then
echo "✅ Image already exists: $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG"
exit 78
else
echo "⚙️ Image does not exist. Will 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:$IMAGE_TAG"
# Enable buildx
- docker buildx create --use
# Build for ARM64
- |
docker buildx build \
--platform linux/arm64 \
--network=host \
-t lila-games/nakama-server:$IMAGE_TAG \
-t lila-games/nakama-server:latest \
--push \
/drone/src
# -----------------------------------------------------
# 4. Push Nakama image to registry
# -----------------------------------------------------
- name: push-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔑 Logging into registry..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "🏷️ Tagging images..."
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:latest
- echo "📤 Pushing images..."
- docker push $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
- 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" \
-v /mnt/omnissiah-vault/data/nakama/modules:/nakama/data/modules \
-v /mnt/omnissiah-vault/data/nakama/storage:/nakama/data \
$REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
# -----------------------------------------------------
# Pipeline trigger
# -----------------------------------------------------
trigger:
event:
- tag

3
.gitignore vendored
View File

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

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# -----------------------------------------------------
# 1. Build the Nakama plugin
# -----------------------------------------------------
FROM golang:1.22 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 AS nakama
# Copy plugin from builder stage
COPY --from=plugin_builder /workspace/build/main.so /nakama/data/modules/main.so
# Default Nakama startup (runs migrations + server)
ENTRYPOINT exec /bin/sh -ecx "/nakama/nakama migrate up --database.address \"$DB_ADDR\" && exec /nakama/nakama --database.address \"$DB_ADDR\" --socket.server_key=\"$SERVER_KEY\" --http.cors=allow_origin:https://games.aetoskia.com"

266
README.md
View File

@@ -1,38 +1,42 @@
# ✅ Project Status Report # ✅ Project Status Report
## Multiplayer Tic-Tac-Toe Platform ## 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**
--- ---
@@ -40,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 |
@@ -159,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

@@ -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

View File

@@ -54,7 +54,7 @@ async def simulate_matchmaking(num_players: int = 6):
elif p not in matches[p.match_id]: elif p not in matches[p.match_id]:
matches[p.match_id].append(p) matches[p.match_id].append(p)
# print(f'player = {p.label} for match = {p.match_id}') # print(f'player = {p.label} for match = {p.match_id}')
print(f'players = {len(matches[p.match_id])} for match = {p.match_id}') # print(f'players = {len(matches[p.match_id])} for match = {p.match_id}')
# stop early if all assigned # stop early if all assigned
if sum(len(v) for v in matches.values()) >= num_players: if sum(len(v) for v in matches.values()) >= num_players:

View File

@@ -45,6 +45,21 @@ func InitModule(
return 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

View File

@@ -29,11 +29,12 @@ func MatchmakerMatched(
propsA := entries[0].GetProperties() propsA := entries[0].GetProperties()
propsB := entries[1].GetProperties() propsB := entries[1].GetProperties()
validModes := map[string]bool{"classic": true, "blitz": true}
modeA, okA := propsA["mode"].(string) modeA, okA := propsA["mode"].(string)
modeB, okB := propsB["mode"].(string) modeB, okB := propsB["mode"].(string)
if !okA || !okB { if !okA || !okB || !validModes[modeA] || !validModes[modeB] {
logger.Warn("MatchmakerMatched missing mode property — ignoring") logger.Warn("MatchmakerMatched missing mode property — ignoring")
return "", nil return "", nil
} }