Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e0475c8a7 | |||
| 1359a75214 | |||
| 6786547950 | |||
| 17a2caea49 | |||
| 8ff199ca10 | |||
| 62c1f3920b | |||
| 73e3f0a7ac | |||
| 21f6698b89 | |||
| 06904143d6 | |||
| ba05aa7c76 | |||
| bfcf977e13 | |||
| 6eca090046 | |||
| 07cb519f55 | |||
| 840d7701ca | |||
| f1f40f5799 | |||
| 0d8d3785fc | |||
| 65b5ef4660 | |||
| 95381c2a56 | |||
| ead7ad2c35 | |||
| 4a833dc258 | |||
| 37c090cf64 |
160
.drone.yml
Normal file
160
.drone.yml
Normal 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
3
.gitignore
vendored
@@ -2,4 +2,5 @@
|
|||||||
.run
|
.run
|
||||||
.venv
|
.venv
|
||||||
/vendor/
|
/vendor/
|
||||||
/build/
|
/build/
|
||||||
|
.env
|
||||||
|
|||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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
266
README.md
@@ -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: **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
|
* 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: **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 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: **6–8 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.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**
|
## **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:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
106
plugins/match.go
106
plugins/match.go
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user