15 Commits

Author SHA1 Message Date
df0f502191 common files for board games
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-01 18:25:22 +05:30
16355d8028 winner 0 index causing it to draw. using username for winner string. break the highliting X or 0 for cell, which was broken anyway 2025-12-01 18:24:52 +05:30
7671e9b2cc feat: add draw-state support using game_over flag and update UI handling
Updated match data callback to interpret { game_over: true, winner: -1 } as a draw.

Added winner = "draw" UI state for display and disabling board interactions.

Updated status text in Board component to show “Draw!” when applicable.

Adjusted winner highlighting logic to avoid highlighting any symbol during draw.

Ensured ongoing games always set winner = null for consistent behavior.
2025-12-01 18:16:46 +05:30
fa02e8b4e4 feat: update UI & Nakama provider for multi-game support and new match state format
Add PlayerModel interface and switch board/player logic to full player objects

Update matchmaking to require { game, mode } metadata

Replace lastModeRef with unified gameMetadataRef

Fix sendMatchData to send wrapped {data:{row,col}} payload

Update TicTacToe state handling (winner logic, board.grid)

Adjust UI to read symbols from player.metadata.symbol

Update matching logic to find player index via player.user_id

Improve safety checks for missing game/mode in matchmaking
2025-12-01 18:12:18 +05:30
fc29111fe1 Update README.md 2025-12-01 08:30:29 +00:00
5b49e5d584 proper ci cd
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-30 01:23:51 +05:30
55aac72bd2 git gud
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:24:30 +05:30
b10639316e adding port as defaults to 7340
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:15:50 +05:30
d782832fc5 logging connection params
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:08:27 +05:30
b25cd1a039 logging connection params
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:06:34 +05:30
e269dfc208 direct push
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 19:28:08 +05:30
066f8fbea5 fixes 2025-11-29 19:27:59 +05:30
4a3daf7d8c SSL configuration in Dockerfile and drone.yml
Some checks reported errors
continuous-integration/drone/tag Build was killed
2025-11-29 19:22:06 +05:30
13ad4e08d2 SSL configuration
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:16:15 +05:30
7e35cf0c31 fixes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:04:17 +05:30
9 changed files with 293 additions and 49 deletions

View File

@@ -70,6 +70,8 @@ steps:
from_secret: WS_PORT from_secret: WS_PORT
WS_SKEY: WS_SKEY:
from_secret: WS_SKEY from_secret: WS_SKEY
WS_SSL:
from_secret: WS_SSL
volumes: volumes:
- name: dockersock - name: dockersock
path: /var/run/docker.sock path: /var/run/docker.sock
@@ -82,6 +84,7 @@ steps:
--build-arg VITE_WS_HOST="$WS_HOST" \ --build-arg VITE_WS_HOST="$WS_HOST" \
--build-arg VITE_WS_PORT="$WS_PORT" \ --build-arg VITE_WS_PORT="$WS_PORT" \
--build-arg VITE_WS_SKEY="$WS_SKEY" \ --build-arg VITE_WS_SKEY="$WS_SKEY" \
--build-arg VITE_WS_SSL="$WS_SSL" \
-t lila-games/tic-tac-toe-ui:$IMAGE_TAG \ -t lila-games/tic-tac-toe-ui:$IMAGE_TAG \
-t lila-games/tic-tac-toe-ui:latest \ -t lila-games/tic-tac-toe-ui:latest \
/drone/src /drone/src
@@ -118,7 +121,7 @@ steps:
path: /var/run/docker.sock path: /var/run/docker.sock
commands: commands:
- echo "🛑 Stopping old container..." - echo "🛑 Stopping old container..."
- docker rm -f blog || true - docker rm -f tic-tac-toe-ui || true
- name: run-container - name: run-container
image: docker:24 image: docker:24
@@ -131,8 +134,8 @@ steps:
- echo "🚀 Starting container lila-games/tic-tac-toe-ui:$IMAGE_TAG ..." - echo "🚀 Starting container lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
- | - |
docker run -d \ docker run -d \
--name blog \ --name tic-tac-toe-ui \
-p 3002:3000 \ -p 3003:3000 \
-e NODE_ENV=production \ -e NODE_ENV=production \
--restart always \ --restart always \
lila-games/tic-tac-toe-ui:$IMAGE_TAG lila-games/tic-tac-toe-ui:$IMAGE_TAG

View File

@@ -17,11 +17,12 @@ COPY . .
ARG VITE_WS_HOST ARG VITE_WS_HOST
ARG VITE_WS_PORT ARG VITE_WS_PORT
ARG VITE_WS_SKEY ARG VITE_WS_SKEY
ARG VITE_WS_SSL
# Export them as actual environment variables (Vite needs ENV) # Export them as actual environment variables (Vite needs ENV)
ENV VITE_WS_HOST=${VITE_WS_HOST} ENV VITE_WS_HOST=${VITE_WS_HOST}
ENV VITE_WS_PORT=${VITE_WS_PORT} ENV VITE_WS_PORT=${VITE_WS_PORT}
ENV VITE_WS_SKEY=${VITE_WS_SKEY} ENV VITE_WS_SSL=${VITE_WS_SSL}
# Build # Build
RUN npm run build RUN npm run build

230
README.md
View File

@@ -1,41 +1,225 @@
# Material UI - React Router example in TypeScript # tic-tac-toe-ui — Multiplayer Game Client (React + TypeScript + Vite)
## How to use A fully functional multiplayer Tic-Tac-Toe game client built using **React + TypeScript**, powered by **Nakama WebSocket real-time networking**, and delivered as a tiny **production-optimized Vite build** (served via BusyBox/Docker).
Download the example [or clone the repo](https://github.com/mui/material-ui): This UI communicates with the authoritative backend (`tic-tac-toe`) to deliver a secure, synced, cheat-proof multiplayer experience.
<!-- #target-branch-reference --> ---
```bash ## 🎮 Overview
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-ui-react-router-ts
cd material-ui-react-router-ts This repository contains the front-end implementation of the Tic-Tac-Toe multiplayer platform.
The client supports:
* Device-based authentication
* Full matchmaking lifecycle
* Real-time gameplay with WebSockets
* Authoritative state rendering
* Leaderboard browsing
* Game result screens
* A Vite-powered environment system for dynamic host/SSL selection
This UI is production-ready and deployable to any server or container environment.
---
## ⭐ Features
* **React + TypeScript UI**
* **WebSocket real-time gameplay** using Nakama JS
* **Matchmaking flow:** queue → ticket → match → gameplay
* **Authoritative state updates** (OpCode 2)
* **Secure device authentication** (device UUID → session)
* **Leaderboard view** over Nakama's leaderboard API
* **Production Docker image:** Node → Vite → BusyBox
* **Environment-based configuration** for host/SSL
---
## 🧩 Architecture
### Frontend System Diagram
```mermaid
flowchart LR
User[Browser] --> UI[React + TS + Vite]
UI -->|WebSocket| Nakama
UI -->|HTTP| Nakama
UI --> Leaderboard[Leaderboard API]
UI --> Matchmaking[Matchmaker API]
``` ```
Install it and run: ---
```bash ## 🛠 Tech Stack
npm install
npm run dev * **React 18** (TypeScript)
* **Vite.js** (build system)
* **Nakama JavaScript Client**
* **Plain CSS** for styling
* **WebSockets (SSL / non-SSL selectable)**
* **Docker (multi-stage build)**
---
## 🔧 Environment Variables (Vite)
These are injected at build time:
```
VITE_WS_HOST=nakama.aetoskia.com
VITE_WS_PORT=443
VITE_WS_SKEY=secret
VITE_WS_SSL=true
``` ```
or: Meaning:
<!-- #target-branch-reference --> * **VITE_WS_HOST** → Nakama host (domain or IP)
* **VITE_WS_PORT** → Port for WebSocket/API
* **VITE_WS_SKEY** → Nakama server key
* **VITE_WS_SSL** → `true` for wss://, `false` for ws://
[![Edit on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts) ---
[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts) ## 🔌 Runtime Flow
## The idea behind the example ### Authentication
<!-- #host-reference --> * UI generates a device UUID
* Calls `client.authenticateDevice()`
* Stores session in React state
This example demonstrates how you can use Material UI with [React Router](https://reactrouter.com/) in [TypeScript](https://github.com/Microsoft/TypeScript). ### Matchmaking
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.
## What's next? 1. User selects mode (classic / blitz)
2. joins the matchmaking queue
3. Waits for matchmaker ticket
4. Auto-joins the match when assigned
<!-- #host-reference --> ### Gameplay
You now have a working example project. * User sends moves via OpCode **1**
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section. * Server validates + broadcasts authoritative board via OpCode **2**
* UI re-renders board state from server packets
### End of Game
* Player sees win/lose/draw
* Can return to home or matchmaking
---
## 🎨 Styling
Styling uses **plain CSS** via a single `styles.css` file.
Simple, responsive layout using Flexbox.
---
## 🐳 Docker (Production Build)
### Dockerfile Overview
```
# Stage 1: Build
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the app
COPY . .
# Build arguments
ARG VITE_WS_HOST
ARG VITE_WS_PORT
ARG VITE_WS_SKEY
ARG VITE_WS_SSL
# Export them as actual environment variables (Vite needs ENV)
ENV VITE_WS_HOST=${VITE_WS_HOST}
ENV VITE_WS_PORT=${VITE_WS_PORT}
ENV VITE_WS_SSL=${VITE_WS_SSL}
# Build
RUN npm run build
# Stage 2: Static file server (BusyBox)
FROM busybox:latest
WORKDIR /app
# Copy only build frontend files
COPY --from=builder /app/dist /app
# Expose port
EXPOSE 3000
# Default command
CMD ["busybox", "httpd", "-f", "-p", "3000"]
```
Produces an extremely lightweight production image.
---
## 🧪 Testing
Manual testing validated:
* Full matchmaking loop
* Game state sync
* Invalid move handling (server rejections)
* Disconnect behaviour
* Leaderboard retrieval
Pending:
* Stress tests
* Mobile responsiveness
* Reconnect logic
---
## 📈 Deployment
### Supported:
* Docker on any Linux host
* Raspberry Pi (ARM)
* Google Cloud Run / Compute Engine
* Traefik reverse proxy via `games.aetoskia.com`
### Example Deployment via Docker
```
docker run -d \
-p 3003:3003 \
--restart always \
tic-tac-toe-ui:latest
```
Traefik HTTPS routes:
* **games.aetoskia.com** → UI
---
## 🗺️ Roadmap
* Rematch flow
* Reconnect/resume after refresh
* Improved animations
* Mobile UI redesign
* Centralized error handling
---

View File

@@ -1,6 +1,6 @@
{ {
"name": "tictactoe-vite", "name": "tictactoe-vite",
"version": "v0.2.2", "version": "v1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -3,11 +3,19 @@ import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider"; import { useNakama } from "./providers/NakamaProvider";
import getHaiku from "./utils/haikus"; import getHaiku from "./utils/haikus";
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
interface BoardProps { interface BoardProps {
board: string[][]; board: string[][];
turn: number; turn: number;
winner: string | null; winner: string | null;
players: string[]; gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null; myUserId: string | null;
onCellClick: (row: number, col: number) => void; onCellClick: (row: number, col: number) => void;
} }
@@ -16,21 +24,26 @@ export default function Board({
board, board,
turn, turn,
winner, winner,
gameOver,
players, players,
myUserId, myUserId,
onCellClick, onCellClick,
}: BoardProps) { }: BoardProps) {
const myIndex = players.indexOf(myUserId ?? ""); const myIndex = players.findIndex(p => p.user_id === myUserId);
const gameReady = players.length === 2; const gameReady = players.length === 2;
const { const {
matchId matchId
} = useNakama(); } = useNakama();
const mySymbol = const mySymbol =
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null; myIndex !== null && players[myIndex]
? players[myIndex].metadata?.symbol ?? null
: null;
const opponentSymbol = const opponentSymbol =
mySymbol === "X" ? "O" : mySymbol === "O" ? "X" : null; myIndex !== null && players.length === 2
? players[1 - myIndex].metadata?.symbol ?? null
: null;
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex; const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
@@ -42,6 +55,8 @@ export default function Board({
status = "Waiting for opponent..."; status = "Waiting for opponent...";
} else if (winner) { } else if (winner) {
status = `Winner: ${winner}`; status = `Winner: ${winner}`;
} else if (gameOver) {
status = `Draw!!!`;
} else if (myIndex === -1) { } else if (myIndex === -1) {
status = "Spectating"; status = "Spectating";
} else { } else {

View File

@@ -41,7 +41,10 @@ export default function Player({
setIsQueueing(true); setIsQueueing(true);
try { try {
const ticket = await joinMatchmaker(selectedMode); const ticket = await joinMatchmaker({
game: 'tictactoe',
mode: selectedMode,
});
console.log("Queued:", ticket); console.log("Queued:", ticket);
} catch (err) { } catch (err) {
console.error("Matchmaking failed:", err); console.error("Matchmaking failed:", err);

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider"; import { useNakama } from "./providers/NakamaProvider";
import Board from "./Board"; import Board from "./Board";
import Player from "./Player"; import Player from "./Player";
import { PlayerModel } from "./Board";
export default function TicTacToe() { export default function TicTacToe() {
const [board, setBoard] = useState<string[][]>([ const [board, setBoard] = useState<string[][]>([
@@ -12,7 +13,8 @@ export default function TicTacToe() {
]); ]);
const [turn, setTurn] = useState<number>(0); const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null); const [winner, setWinner] = useState<string | null>(null);
const [players, setPlayers] = useState<string[]>([]); const [gameOver, setGameOver] = useState<boolean | null>(null);
const [players, setPlayers] = useState<PlayerModel[]>([]);
const { sendMatchData, onMatchData, matchId, session } = useNakama(); const { sendMatchData, onMatchData, matchId, session } = useNakama();
@@ -26,9 +28,18 @@ export default function TicTacToe() {
const state = msg.data; const state = msg.data;
console.log("Match state:", state); console.log("Match state:", state);
setBoard(state.board); setBoard(state.board.grid);
setTurn(state.turn); setTurn(state.turn);
setWinner(state.winner || null); setGameOver(state.game_over);
if (state.winner >= 0) {
setWinner(state.players[state.winner].username);
// } else if (state.game_over) {
// // Game ended but winner = -1 → draw
// setWinner("draw");
} else {
// Ongoing game, no winner
setWinner(null);
}
setPlayers(state.players || []); setPlayers(state.players || []);
} }
} }
@@ -50,7 +61,7 @@ export default function TicTacToe() {
function handleCellClick(row: number, col: number) { function handleCellClick(row: number, col: number) {
if (!matchId) return; if (!matchId) return;
sendMatchData(matchId, 1, { row, col }); // OpMove=1 sendMatchData(matchId, 1, {data: {row, col}});
} }
return ( return (
@@ -108,6 +119,7 @@ export default function TicTacToe() {
board={board} board={board}
turn={turn} turn={turn}
winner={winner} winner={winner}
gameOver={gameOver}
players={players} players={players}
myUserId={session?.user_id ?? null} myUserId={session?.user_id ?? null}
onCellClick={handleCellClick} onCellClick={handleCellClick}

View File

@@ -25,6 +25,11 @@ function getOrCreateDeviceId(): string {
return id; return id;
} }
type GameMetadata = {
game: string;
mode: string;
};
export interface NakamaContextType { export interface NakamaContextType {
client: Client; client: Client;
socket: Socket | null; socket: Socket | null;
@@ -33,7 +38,7 @@ export interface NakamaContextType {
loginOrRegister(username: string): Promise<void>; loginOrRegister(username: string): Promise<void>;
logout(): Promise<void>; logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>; joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
joinMatch(matchId: string): Promise<void>; joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void; sendMatchData(matchId: string, op: number, data: object): void;
@@ -46,18 +51,26 @@ export interface NakamaContextType {
export const NakamaContext = createContext<NakamaContextType>(null!); export const NakamaContext = createContext<NakamaContextType>(null!);
export function NakamaProvider({ children }: { children: React.ReactNode }) { export function NakamaProvider({ children }: { children: React.ReactNode }) {
console.log(
"[Nakama] Initializing...",
// import.meta.env.VITE_WS_SKEY,
import.meta.env.VITE_WS_HOST,
import.meta.env.VITE_WS_PORT,
import.meta.env.VITE_WS_SSL === "true"
);
const [client] = useState( const [client] = useState(
() => new Client( () => new Client(
import.meta.env.VITE_WS_SKEY, import.meta.env.VITE_WS_SKEY,
import.meta.env.VITE_WS_HOST, import.meta.env.VITE_WS_HOST,
import.meta.env.VITE_WS_PORT import.meta.env.VITE_WS_PORT,
import.meta.env.VITE_WS_SSL === "true"
) )
); );
const gameMetadataRef = React.useRef<GameMetadata | null>(null);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
const [matchId, setMatchId] = useState<string | null>(null); const [matchId, setMatchId] = useState<string | null>(null);
const lastModeRef = React.useRef<string | null>(null);
const socketRef = React.useRef<Socket | null>(null); const socketRef = React.useRef<Socket | null>(null);
async function autoLogin() { async function autoLogin() {
@@ -116,8 +129,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
const newSession = await getSession(username); const newSession = await getSession(username);
setSession(newSession); setSession(newSession);
// create a socket (new Nakama 3.x signature) const s = client.createSocket(
const s = client.createSocket(undefined, undefined); // no SSL on localhost import.meta.env.VITE_WS_SSL === "true",
undefined
);
await s.connect(newSession, true); await s.connect(newSession, true);
setSocket(s); setSocket(s);
socketRef.current = s; socketRef.current = s;
@@ -130,9 +145,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
if (!matched.match_id) { if (!matched.match_id) {
console.warn("[Nakama] Match rejected by server. Auto-requeueing..."); console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
if (lastModeRef.current) { if (gameMetadataRef.current) {
try { try {
await joinMatchmaker(lastModeRef.current); await joinMatchmaker(gameMetadataRef.current);
} catch (e) { } catch (e) {
console.error("[Nakama] Requeue failed:", e); console.error("[Nakama] Requeue failed:", e);
} }
@@ -175,19 +190,28 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// ---------------------------------------------------- // ----------------------------------------------------
// MATCHMAKING // MATCHMAKING
// ---------------------------------------------------- // ----------------------------------------------------
async function joinMatchmaker(mode: string) { async function joinMatchmaker(gameMetadata: GameMetadata) {
const socket = socketRef.current; const socket = socketRef.current;
const game = gameMetadata.game;
const mode = gameMetadata.mode;
if (!socket) throw new Error("socket missing"); if (!socket) throw new Error("socket missing");
console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`); if (!game || game.trim() === "") {
throw new Error("Matchmaking requires a game name");
}
if (!mode || mode.trim() === "") {
throw new Error("Matchmaking requires a mode");
}
console.log(`[Nakama] Matchmaking... game="${game}" mode="${mode}"`);
const ticket: MatchmakerTicket = await socket.addMatchmaker( const ticket: MatchmakerTicket = await socket.addMatchmaker(
`*`, // query `*`, // query
2, // min count 2, // min count
2, // max count 2, // max count
{ mode } // stringProperties { game, mode }
); );
lastModeRef.current = mode; gameMetadataRef.current = { game, mode };
return ticket.ticket; return ticket.ticket;
} }
@@ -207,6 +231,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// ---------------------------------------------------- // ----------------------------------------------------
function sendMatchData(matchId: string, op: number, data: object) { function sendMatchData(matchId: string, op: number, data: object) {
if (!socket) return; if (!socket) return;
console.log("[Nakama] Sending match state:", matchId, op, data);
socket.sendMatchState(matchId, op, JSON.stringify(data)); socket.sendMatchState(matchId, op, JSON.stringify(data));
} }

1
src/vite-env.d.ts vendored
View File

@@ -4,6 +4,7 @@ interface ImportMetaEnv {
readonly VITE_WS_HOST: string; readonly VITE_WS_HOST: string;
readonly VITE_WS_PORT: string; readonly VITE_WS_PORT: string;
readonly VITE_WS_SKEY: string; readonly VITE_WS_SKEY: string;
readonly VITE_WS_SSL: string;
} }
interface ImportMeta { interface ImportMeta {