Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df0f502191 | |||
| 16355d8028 | |||
| 7671e9b2cc | |||
| fa02e8b4e4 | |||
| fc29111fe1 | |||
| 5b49e5d584 | |||
| 55aac72bd2 | |||
| b10639316e | |||
| d782832fc5 | |||
| b25cd1a039 | |||
| e269dfc208 | |||
| 066f8fbea5 | |||
| 4a3daf7d8c | |||
| 13ad4e08d2 | |||
| 7e35cf0c31 |
@@ -70,6 +70,8 @@ steps:
|
||||
from_secret: WS_PORT
|
||||
WS_SKEY:
|
||||
from_secret: WS_SKEY
|
||||
WS_SSL:
|
||||
from_secret: WS_SSL
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
@@ -82,6 +84,7 @@ steps:
|
||||
--build-arg VITE_WS_HOST="$WS_HOST" \
|
||||
--build-arg VITE_WS_PORT="$WS_PORT" \
|
||||
--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:latest \
|
||||
/drone/src
|
||||
@@ -118,7 +121,7 @@ steps:
|
||||
path: /var/run/docker.sock
|
||||
commands:
|
||||
- echo "🛑 Stopping old container..."
|
||||
- docker rm -f blog || true
|
||||
- docker rm -f tic-tac-toe-ui || true
|
||||
|
||||
- name: run-container
|
||||
image: docker:24
|
||||
@@ -131,8 +134,8 @@ steps:
|
||||
- echo "🚀 Starting container lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
|
||||
- |
|
||||
docker run -d \
|
||||
--name blog \
|
||||
-p 3002:3000 \
|
||||
--name tic-tac-toe-ui \
|
||||
-p 3003:3000 \
|
||||
-e NODE_ENV=production \
|
||||
--restart always \
|
||||
lila-games/tic-tac-toe-ui:$IMAGE_TAG
|
||||
|
||||
@@ -17,11 +17,12 @@ COPY . .
|
||||
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_SKEY=${VITE_WS_SKEY}
|
||||
ENV VITE_WS_SSL=${VITE_WS_SSL}
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
230
README.md
230
README.md
@@ -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
|
||||
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
|
||||
## 🎮 Overview
|
||||
|
||||
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
|
||||
npm install
|
||||
npm run dev
|
||||
## 🛠 Tech Stack
|
||||
|
||||
* **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://
|
||||
|
||||
[](https://codesandbox.io/p/sandbox/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts)
|
||||
---
|
||||
|
||||
[](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).
|
||||
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.
|
||||
### Matchmaking
|
||||
|
||||
## 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.
|
||||
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section.
|
||||
* User sends moves via OpCode **1**
|
||||
* 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
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tictactoe-vite",
|
||||
"version": "v0.2.2",
|
||||
"version": "v1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,11 +3,19 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
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 {
|
||||
board: string[][];
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
players: string[];
|
||||
gameOver: boolean | null;
|
||||
players: PlayerModel[];
|
||||
myUserId: string | null;
|
||||
onCellClick: (row: number, col: number) => void;
|
||||
}
|
||||
@@ -16,21 +24,26 @@ export default function Board({
|
||||
board,
|
||||
turn,
|
||||
winner,
|
||||
gameOver,
|
||||
players,
|
||||
myUserId,
|
||||
onCellClick,
|
||||
}: BoardProps) {
|
||||
const myIndex = players.indexOf(myUserId ?? "");
|
||||
const myIndex = players.findIndex(p => p.user_id === myUserId);
|
||||
const gameReady = players.length === 2;
|
||||
const {
|
||||
matchId
|
||||
} = useNakama();
|
||||
|
||||
const mySymbol =
|
||||
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null;
|
||||
myIndex !== null && players[myIndex]
|
||||
? players[myIndex].metadata?.symbol ?? null
|
||||
: null;
|
||||
|
||||
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;
|
||||
|
||||
@@ -42,6 +55,8 @@ export default function Board({
|
||||
status = "Waiting for opponent...";
|
||||
} else if (winner) {
|
||||
status = `Winner: ${winner}`;
|
||||
} else if (gameOver) {
|
||||
status = `Draw!!!`;
|
||||
} else if (myIndex === -1) {
|
||||
status = "Spectating";
|
||||
} else {
|
||||
|
||||
@@ -41,7 +41,10 @@ export default function Player({
|
||||
setIsQueueing(true);
|
||||
|
||||
try {
|
||||
const ticket = await joinMatchmaker(selectedMode);
|
||||
const ticket = await joinMatchmaker({
|
||||
game: 'tictactoe',
|
||||
mode: selectedMode,
|
||||
});
|
||||
console.log("Queued:", ticket);
|
||||
} catch (err) {
|
||||
console.error("Matchmaking failed:", err);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import Board from "./Board";
|
||||
import Player from "./Player";
|
||||
import { PlayerModel } from "./Board";
|
||||
|
||||
export default function TicTacToe() {
|
||||
const [board, setBoard] = useState<string[][]>([
|
||||
@@ -12,7 +13,8 @@ export default function TicTacToe() {
|
||||
]);
|
||||
const [turn, setTurn] = useState<number>(0);
|
||||
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();
|
||||
|
||||
@@ -26,9 +28,18 @@ export default function TicTacToe() {
|
||||
const state = msg.data;
|
||||
console.log("Match state:", state);
|
||||
|
||||
setBoard(state.board);
|
||||
setBoard(state.board.grid);
|
||||
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 || []);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +61,7 @@ export default function TicTacToe() {
|
||||
function handleCellClick(row: number, col: number) {
|
||||
if (!matchId) return;
|
||||
|
||||
sendMatchData(matchId, 1, { row, col }); // OpMove=1
|
||||
sendMatchData(matchId, 1, {data: {row, col}});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,6 +119,7 @@ export default function TicTacToe() {
|
||||
board={board}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
gameOver={gameOver}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
|
||||
@@ -25,6 +25,11 @@ function getOrCreateDeviceId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
type GameMetadata = {
|
||||
game: string;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export interface NakamaContextType {
|
||||
client: Client;
|
||||
socket: Socket | null;
|
||||
@@ -33,7 +38,7 @@ export interface NakamaContextType {
|
||||
|
||||
loginOrRegister(username: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
joinMatchmaker(mode: string): Promise<string>;
|
||||
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
|
||||
joinMatch(matchId: string): Promise<void>;
|
||||
|
||||
sendMatchData(matchId: string, op: number, data: object): void;
|
||||
@@ -46,18 +51,26 @@ export interface NakamaContextType {
|
||||
export const NakamaContext = createContext<NakamaContextType>(null!);
|
||||
|
||||
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(
|
||||
() => new Client(
|
||||
import.meta.env.VITE_WS_SKEY,
|
||||
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 [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const lastModeRef = React.useRef<string | null>(null);
|
||||
const socketRef = React.useRef<Socket | null>(null);
|
||||
|
||||
async function autoLogin() {
|
||||
@@ -116,8 +129,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
const newSession = await getSession(username);
|
||||
setSession(newSession);
|
||||
|
||||
// create a socket (new Nakama 3.x signature)
|
||||
const s = client.createSocket(undefined, undefined); // no SSL on localhost
|
||||
const s = client.createSocket(
|
||||
import.meta.env.VITE_WS_SSL === "true",
|
||||
undefined
|
||||
);
|
||||
await s.connect(newSession, true);
|
||||
setSocket(s);
|
||||
socketRef.current = s;
|
||||
@@ -130,9 +145,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!matched.match_id) {
|
||||
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
|
||||
|
||||
if (lastModeRef.current) {
|
||||
if (gameMetadataRef.current) {
|
||||
try {
|
||||
await joinMatchmaker(lastModeRef.current);
|
||||
await joinMatchmaker(gameMetadataRef.current);
|
||||
} catch (e) {
|
||||
console.error("[Nakama] Requeue failed:", e);
|
||||
}
|
||||
@@ -175,19 +190,28 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
// ----------------------------------------------------
|
||||
// MATCHMAKING
|
||||
// ----------------------------------------------------
|
||||
async function joinMatchmaker(mode: string) {
|
||||
async function joinMatchmaker(gameMetadata: GameMetadata) {
|
||||
const socket = socketRef.current;
|
||||
const game = gameMetadata.game;
|
||||
const mode = gameMetadata.mode;
|
||||
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(
|
||||
`*`, // query
|
||||
2, // min count
|
||||
2, // max count
|
||||
{ mode } // stringProperties
|
||||
{ game, mode }
|
||||
);
|
||||
|
||||
lastModeRef.current = mode;
|
||||
gameMetadataRef.current = { game, mode };
|
||||
|
||||
return ticket.ticket;
|
||||
}
|
||||
@@ -207,6 +231,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
// ----------------------------------------------------
|
||||
function sendMatchData(matchId: string, op: number, data: object) {
|
||||
if (!socket) return;
|
||||
console.log("[Nakama] Sending match state:", matchId, op, data);
|
||||
socket.sendMatchState(matchId, op, JSON.stringify(data));
|
||||
}
|
||||
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_WS_HOST: string;
|
||||
readonly VITE_WS_PORT: string;
|
||||
readonly VITE_WS_SKEY: string;
|
||||
readonly VITE_WS_SSL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user