22 Commits

Author SHA1 Message Date
51b051b34c hiding game mode for now as there's no different game modes for either tictactoe or battleship 2025-12-03 22:05:22 +05:30
eb6749dc0b feat(ui): add dynamic game board selection and hide board until match join
Added renderGameBoard() resolver for dynamic board rendering

Board now hidden before match join

Game auto-selected based on metadata.game from Player matchmaking

Updated header to use dynamic game name

Removed hardcoded Battleship board
2025-12-03 22:01:44 +05:30
ee31b010ac fixes 2025-12-03 21:40:18 +05:30
81a54aa93e feat(ui/battleship): integrate BattleshipBoard and metadata-driven placement/battle flow
- Added metadata state to App and wired incoming match metadata.
- Added Fleet + FLEET_ORDER in BattleShipBoard to drive ship placement order.
- Added nextShip + nextShipSize calculation for guided placement.
- Updated handlePlace and handleShoot to send structured payloads (action + data).
- Added lobby/placement/battle status messages.
- Updated grids to use shipBoard + shipName/shipSize props instead of generic grid.
- Fixed metadata access (state.Metadata vs state.metadata).
- Consolidated PlacementGrid usage and disabled it during battle phase.
- Added logging for debugging incoming battleship boards.
2025-12-03 21:01:41 +05:30
fe1cacb5ed feat(battleship): add complete Battleship game UI with placement & battle phases
- Implement BattleshipBoard with phase-based rendering (placement/battle)
- Add PlacementGrid for ship placement interaction
- Add ShotGrid for firing UI with turn validation
- Integrate match metadata (pX_placed, pX_ready, phase)
- Connect UI to Nakama sendMatchData (place + shoot actions)
- Add real-time board rendering for ships and shots
- Add status line, turn handling, and winner display
- Ensure compatibility with new backend ApplyMove/ApplyPlacement logic
2025-12-03 19:27:47 +05:30
2b0af9fd1f feat(tictactoe): migrate to multi-board state structure
- Replaced single `board` state with `boards` map in App.tsx
- Updated state parsing to use `state.boards` instead of `state.board.grid`
- Updated TicTacToeBoard props to accept `boards` instead of `board`
- Safely extracted TicTacToe board using `boards['tictactoe']?.grid ?? null`
- Added loading fallback when board is not yet available
- Updated rendering guards to prevent undefined map errors

This change fully aligns the frontend with the new multi-board MatchState
introduced on the server (supports TicTacToe, Battleship, and future games).
2025-12-03 17:36:29 +05:30
7b677653a7 cleanup 2025-12-01 20:58:21 +05:30
5c75541c25 de queue on cancle queue rather than before starting new queue 2025-12-01 20:58:08 +05:30
83ae342499 feat(matchmaking): add selectedGame support and implement exitMatchmaker to clear active tickets
Added selectedGame state and UI dropdown

Updated startQueue() to pass { game, mode } metadata

Added exitMatchmaker() to remove existing ticket

Stored active matchmaker ticket in context

Prevents duplicate matchmaker ticket errors
2025-12-01 20:55:24 +05:30
cc1f45457c refactoring game for separate folders for game boards and common logic for player 2025-12-01 20:36:46 +05:30
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
22 changed files with 1304 additions and 153 deletions

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
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://
[![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).
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
---

View File

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

View File

@@ -1,21 +1,57 @@
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 "./models/player";
export default function TicTacToe() {
const [board, setBoard] = useState<string[][]>([
["", "", ""],
["", "", ""],
["", "", ""]
]);
import TicTacToeBoard from "./games/tictactoe/TicTacToeBoard";
import BattleShipBoard from "./games/battleship/BattleShipBoard";
export default function App() {
// setting up a 2D game boards
const [boards, setBoards] = useState<Record<string, { grid: string[][] }>>({});
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 [metadata, setMetadata] = useState<Record<string, any>>({});
const { sendMatchData, onMatchData, matchId, session } = useNakama();
function renderGameBoard() {
if (!matchId || !metadata?.game) return null;
switch (metadata.game) {
case "tictactoe":
return (
<TicTacToeBoard
boards={boards}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}
/>
);
case "battleship":
return (
<BattleShipBoard
boards={boards}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
metadata={metadata}
/>
);
default:
return <div>Unknown game: {metadata.game}</div>;
}
}
// ------------------------------------------
// MATCH DATA CALLBACK (from Player component)
// ------------------------------------------
@@ -26,10 +62,20 @@ export default function TicTacToe() {
const state = msg.data;
console.log("Match state:", state);
setBoard(state.board);
setBoards(state.boards);
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 || []);
setMetadata(state.metadata || {});
}
}
@@ -50,7 +96,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 (
@@ -77,7 +123,7 @@ export default function TicTacToe() {
letterSpacing: "1px",
}}
>
Tic Tac Toe
Games
</header>
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
@@ -104,14 +150,7 @@ export default function TicTacToe() {
minWidth: "300px",
}}
>
<Board
board={board}
turn={turn}
winner={winner}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}
/>
{renderGameBoard()}
</div>
</motion.div>
</div>

View File

@@ -1,6 +1,5 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Leaderboard from "./Leaderboard";
import { useNakama } from "./providers/NakamaProvider";
export default function Player({
@@ -15,11 +14,13 @@ export default function Player({
logout,
onMatchData,
joinMatchmaker,
exitMatchmaker,
} = useNakama();
const [username, setUsername] = useState(
localStorage.getItem("username") ?? ""
);
const [selectedGame, setSelectedGame] = useState("tictactoe");
const [selectedMode, setSelectedMode] = useState("classic");
const [isQueueing, setIsQueueing] = useState(false);
const isRegistered = localStorage.getItem("registered") === "yes";
@@ -37,11 +38,18 @@ export default function Player({
// ------------------------------------------
// MATCHMAKING
// ------------------------------------------
async function startQueue(selectedMode: string) {
async function startQueue(
selectedGame: string,
selectedMode: string
) {
setIsQueueing(true);
const gameMetadata = {
game: selectedGame,
mode: selectedMode,
}
try {
const ticket = await joinMatchmaker(selectedMode);
const ticket = await joinMatchmaker(gameMetadata);
console.log("Queued:", ticket);
} catch (err) {
console.error("Matchmaking failed:", err);
@@ -49,10 +57,17 @@ export default function Player({
}
}
function cancelQueue() {
async function cancelQueue(
selectedGame: string,
selectedMode: string
) {
setIsQueueing(false);
// Nakama matchmaker tickets auto-expire by default in your setup.
// If you later add manual ticket cancel RPC, call it here.
const gameMetadata = {
game: selectedGame,
mode: selectedMode,
}
await exitMatchmaker(gameMetadata)
}
useEffect(() => {
@@ -147,9 +162,9 @@ export default function Player({
</label>
<select
value={selectedMode}
value={selectedGame}
disabled={isQueueing}
onChange={(e) => setSelectedMode(e.target.value)}
onChange={(e) => setSelectedGame(e.target.value)}
style={{
padding: "8px",
margin: "10px 0 16px",
@@ -160,14 +175,35 @@ export default function Player({
border: "1px solid #333",
}}
>
<option value="classic">Classic</option>
<option value="blitz">Blitz</option>
<option value="tictactoe">Tic Tac Toe</option>
<option value="battleship">Battleship</option>
</select>
{/*<select*/}
{/* value={selectedMode}*/}
{/* disabled={isQueueing}*/}
{/* onChange={(e) => setSelectedMode(e.target.value)}*/}
{/* style={{*/}
{/* padding: "8px",*/}
{/* margin: "10px 0 16px",*/}
{/* width: "60%",*/}
{/* borderRadius: "10px",*/}
{/* background: "#222",*/}
{/* color: "white",*/}
{/* border: "1px solid #333",*/}
{/* }}*/}
{/*>*/}
{/* <option value="classic">Classic</option>*/}
{/* <option value="blitz">Blitz</option>*/}
{/*</select>*/}
{!isQueueing && (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => startQueue(selectedMode)}
onClick={() => startQueue(
selectedGame,
selectedMode,
)}
style={{
padding: "10px 20px",
borderRadius: "12px",
@@ -216,7 +252,10 @@ export default function Player({
{/* Cancel button */}
<button
onClick={cancelQueue}
onClick={() => cancelQueue(
selectedGame,
selectedMode,
)}
style={{
marginTop: "10px",
padding: "6px 12px",
@@ -235,7 +274,6 @@ export default function Player({
)}
<div style={{ marginTop: "24px" }}>
<Leaderboard />
</div>
<motion.button

View File

@@ -0,0 +1,147 @@
import React, { useMemo } from "react";
import { motion } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider";
import { PlayerModel } from "../../models/player";
import PlacementGrid from "./placement/PlacementGrid";
import ShotGrid from "./battle/ShotGrid";
interface BattleBoardProps {
boards: Record<string, { grid: string[][] }>;
players: PlayerModel[];
myUserId: string | null;
turn: number;
winner: string | null;
gameOver: boolean | null;
metadata: Record<string, any>;
}
const Fleet: Record<string, number> = {
carrier: 5,
battleship: 4,
cruiser: 3,
submarine: 3,
destroyer: 2,
};
const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"];
export default function BattleShipBoard({
boards,
players,
myUserId,
turn,
winner,
gameOver,
metadata,
}: BattleBoardProps) {
const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex((p) => p.user_id === myUserId);
const oppIndex = myIndex === 0 ? 1 : 0;
const phase = metadata["phase"] ?? "lobby";
const isMyTurn = phase === "battle" && turn === myIndex;
const myShips = boards[`p${myIndex}_ships`]?.grid ?? [[]];
const myShots = boards[`p${myIndex}_shots`] ?.grid ?? [[]];
const placed = metadata[`p${myIndex}_placed`] ?? 0;
const nextShip = FLEET_ORDER[placed] || null;
const nextShipSize = nextShip ? Fleet[nextShip] : null;
// ------------------- PLACE SHIP -------------------
function handlePlace(ship: string, r: number, c: number, dir: "h" | "v") {
sendMatchData(matchId!, 1, {
action: "place",
data: {
ship: ship,
row: r,
col: c,
dir,
}
});
}
// ------------------- SHOOT -------------------
function handleShoot(r: number, c: number) {
sendMatchData(matchId!, 1, {
action: "shoot",
data: {
row: r,
col: c,
}
});
}
// ------------------- STATUS LABEL -------------------
const status = useMemo(() => {
if (phase === "lobby") return `In Lobby`;
if (winner !== null) return `Winner: Player ${winner}`;
if (gameOver) return "Game over — draw";
if (phase === "placement") return `Place your ${nextShip ?? ""}`;
if (myIndex === -1) return "Spectating";
if (!isMyTurn) return "Opponents turn";
return "Your turn";
}, [winner, gameOver, phase, isMyTurn, myIndex, nextShip]);
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 8 }}>{status}</h2>
{/* ---------------- PHASE 1: PLACEMENT ---------------- */}
{phase === "placement" && nextShip && (
<PlacementGrid
shipBoard={myShips}
shipName={nextShip}
shipSize={nextShipSize}
onPlace={handlePlace}
/>
)}
{/* ---------------- PHASE 2: BATTLE ---------------- */}
{phase === "battle" && (
<>
<h3>Your Shots</h3>
<ShotGrid
grid={myShots}
isMyTurn={isMyTurn}
gameOver={!!gameOver}
onShoot={handleShoot}
/>
<h3 style={{ marginTop: "18px" }}>Your Ships</h3>
<PlacementGrid
shipBoard={myShips}
shipName="readonly"
shipSize={0}
onPlace={() => {}}
/>
</>
)}
{/* ---------------- WINNER UI ---------------- */}
{winner !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, scale: [1, 1.05, 1] }}
transition={{ repeat: Infinity, duration: 1.4 }}
style={{
marginTop: 12,
fontSize: "20px",
fontWeight: "bold",
color: "#f1c40f",
}}
>
🎉 Player {winner} Wins! 🎉
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ShotGridProps {
grid: string[][]; // your shots: "", "H", "M"
isMyTurn: boolean; // only clickable on your turn
gameOver: boolean;
onShoot: (row: number, col: number) => void;
}
export default function ShotGrid({
grid,
isMyTurn,
gameOver,
onShoot,
}: ShotGridProps) {
const rows = grid.length;
const cols = grid[0].length;
function handleClick(r: number, c: number) {
if (!isMyTurn || gameOver) return;
if (grid[r][c] !== "") return; // can't shoot twice
onShoot(r, c);
}
return (
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 36px)`,
gap: "4px",
justifyContent: "center",
marginTop: "12px",
}}
>
{grid.map((row, r) =>
row.map((cell, c) => {
const empty = cell === "";
const hit = cell === "H";
const miss = cell === "M";
let bg = "#111";
if (hit) bg = "#e74c3c"; // red for hit
if (miss) bg = "#34495e"; // gray for miss
return (
<motion.button
key={`${r}-${c}`}
whileHover={empty && isMyTurn && !gameOver ? { scale: 1.1 } : {}}
whileTap={empty && isMyTurn && !gameOver ? { scale: 0.9 } : {}}
onClick={() => handleClick(r, c)}
style={{
width: 36,
height: 36,
borderRadius: 4,
border: "1px solid #444",
background: bg,
cursor:
empty && isMyTurn && !gameOver ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: 700,
}}
>
<AnimatePresence>
{hit && (
<motion.span
key="hit"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.2, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
>
H
</motion.span>
)}
{miss && (
<motion.span
key="miss"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.2, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
>
M
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})
)}
</motion.div>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { motion } from "framer-motion";
interface ShipSelectorProps {
remainingShips: string[]; // ex: ["carrier", "battleship", ...]
selectedShip: string | null;
orientation: "h" | "v";
onSelectShip: (ship: string) => void;
onToggleOrientation: () => void;
}
export default function ShipSelector({
remainingShips,
selectedShip,
orientation,
onSelectShip,
onToggleOrientation,
}: ShipSelectorProps) {
return (
<div style={{ marginTop: 12, textAlign: "center" }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.9 }}
style={{
marginBottom: 10,
fontSize: 16,
color: "#ddd",
}}
>
Select ship & orientation
</motion.div>
{/* SHIP BUTTONS */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: "10px",
flexWrap: "wrap",
marginBottom: 14,
}}
>
{remainingShips.map((ship) => {
const active = ship === selectedShip;
return (
<motion.button
key={ship}
onClick={() => onSelectShip(ship)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
style={{
padding: "8px 14px",
borderRadius: 8,
background: active ? "#f1c40f" : "#333",
color: active ? "#000" : "#fff",
border: "2px solid #444",
cursor: "pointer",
fontSize: 14,
}}
>
{ship.toUpperCase()}
</motion.button>
);
})}
</div>
{/* ORIENTATION BUTTON */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
onClick={onToggleOrientation}
style={{
padding: "8px 14px",
borderRadius: 8,
background: "#222",
color: "white",
border: "2px solid #444",
cursor: "pointer",
fontSize: 14,
}}
>
Orientation: <strong>{orientation === "h" ? "Horizontal" : "Vertical"}</strong>
</motion.button>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { motion } from "framer-motion";
interface StatusBarProps {
text: string;
}
export default function StatusBar({ text }: StatusBarProps) {
return (
<motion.div
key={text}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{
marginBottom: 8,
fontSize: "18px",
textAlign: "center",
}}
>
{text}
</motion.div>
);
}

View File

@@ -0,0 +1,169 @@
import React from "react";
import { motion } from "framer-motion";
interface BattleProps {
myIndex: number;
boards: Record<string, { grid: string[][] }>;
turn: number;
winner: string | null;
gameOver: boolean | null;
players: any[];
myUserId: string | null;
onShoot: (row: number, col: number) => void;
}
export default function BattlePhase({
myIndex,
boards,
turn,
winner,
gameOver,
players,
myUserId,
onShoot,
}: BattleProps) {
if (myIndex < 0) return <div>Spectating...</div>;
const myShots = boards[`p${myIndex}_shots`]?.grid;
const myShips = boards[`p${myIndex}_ships`]?.grid;
const isMyTurn = turn === myIndex;
const gameReady = players.length === 2;
if (!myShots || !myShips) return <div>Loading...</div>;
const renderShotCell = (cell: string, r: number, c: number) => {
const disabled =
!isMyTurn ||
gameOver ||
cell === "H" ||
cell === "M"; // can't shoot same cell
const bg =
cell === "H"
? "#e74c3c" // red = hit
: cell === "M"
? "#34495e" // blue/gray = miss
: "#1c1c1c"; // untouched
return (
<motion.div
key={`shot-${r}-${c}`}
onClick={() => !disabled && onShoot(r, c)}
whileHover={!disabled ? { scale: 1.06 } : {}}
whileTap={!disabled ? { scale: 0.85 } : {}}
style={{
width: 38,
height: 38,
border: "1px solid #333",
background: bg,
cursor: disabled ? "not-allowed" : "pointer",
}}
/>
);
};
const renderShipCell = (cell: string, r: number, c: number) => {
const bg =
cell === "S"
? "#2980b9" // ship
: cell === "X"
? "#c0392b" // destroyed
: "#111"; // water
return (
<motion.div
key={`ship-${r}-${c}`}
style={{
width: 38,
height: 38,
border: "1px solid #222",
background: bg,
}}
/>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 10 }}>
{gameOver
? winner === myUserId
? "🎉 Victory! 🎉"
: "💥 Defeat 💥"
: isMyTurn
? "Your Turn — Fire!"
: "Opponent's Turn"}
</h2>
{/* -------------------------
TOP SECTION — YOUR SHOTS
-------------------------- */}
<div style={{ marginBottom: 12, opacity: 0.85 }}>
<strong>Your Shots</strong>
</div>
<motion.div
style={{
display: "grid",
gridTemplateColumns: `repeat(${myShots[0].length}, 38px)`,
gap: 4,
justifyContent: "center",
}}
>
{myShots.map((row, r) =>
row.map((cell, c) => renderShotCell(cell, r, c))
)}
</motion.div>
{/* -------------------------
BOTTOM SECTION — YOUR FLEET
-------------------------- */}
<div style={{ marginTop: 28, marginBottom: 12, opacity: 0.85 }}>
<strong>Your Fleet Status</strong>
</div>
<motion.div
style={{
display: "grid",
gridTemplateColumns: `repeat(${myShips[0].length}, 38px)`,
gap: 4,
justifyContent: "center",
}}
>
{myShips.map((row, r) =>
row.map((cell, c) => renderShipCell(cell, r, c))
)}
</motion.div>
{winner && (
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
scale: [1, 1.06, 1],
}}
transition={{
repeat: Infinity,
duration: 1.4,
ease: "easeInOut",
}}
style={{
color: "#f1c40f",
fontSize: "20px",
marginTop: "18px",
fontWeight: 700,
}}
>
{winner === players[myIndex].user_id
? "🎉 You Win! 🎉"
: "💥 You Lost 💥"}
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
interface BattleStatusProps {
phase: string;
myIndex: number;
turn: number;
players: any[];
lastHit: boolean | null;
winner: string | null;
gameOver: boolean | null;
}
export default function BattleStatus({
phase,
myIndex,
turn,
players,
lastHit,
winner,
gameOver,
}: BattleStatusProps) {
const isMyTurn = turn === myIndex;
// -----------------------------
// STATUS TEXT
// -----------------------------
let statusText = "";
if (gameOver) {
statusText =
winner === players[myIndex]?.user_id ? "🎉 Victory!" : "💥 Defeat";
} else if (phase === "placement") {
statusText = "Place Your Fleet";
} else {
statusText = isMyTurn ? "Your Turn — FIRE!" : "Opponents Turn";
}
// -----------------------------
// Last hit/miss indicator
// -----------------------------
let hitText = null;
if (lastHit === true) hitText = "🔥 HIT!";
else if (lastHit === false) hitText = "💦 MISS";
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{
textAlign: "center",
marginBottom: 14,
color: "#eee",
fontFamily: "sans-serif",
}}
>
{/* MAIN STATUS */}
<motion.div
key={statusText}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{
fontSize: 20,
fontWeight: 700,
marginBottom: 4,
}}
>
{statusText}
</motion.div>
{/* HIT / MISS FEEDBACK */}
<AnimatePresence mode="wait">
{hitText && !gameOver && (
<motion.div
key={hitText}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.25 }}
style={{
color: lastHit ? "#e74c3c" : "#3498db",
fontSize: 18,
fontWeight: 600,
marginTop: 4,
}}
>
{hitText}
</motion.div>
)}
</AnimatePresence>
{/* PHASE */}
<div style={{ marginTop: 6, opacity: 0.55, fontSize: 14 }}>
Phase: <strong>{phase}</strong>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,145 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
const fleet = [
{ name: "carrier", size: 5 },
{ name: "battleship", size: 4 },
{ name: "cruiser", size: 3 },
{ name: "submarine", size: 3 },
{ name: "destroyer", size: 2 },
];
interface PlacementProps {
myIndex: number;
boards: Record<string, { grid: string[][] }>;
metadata: Record<string, any>;
onPlace: (ship: string, row: number, col: number, dir: string) => void;
players: any[];
}
export default function PlacementPhase({
myIndex,
boards,
metadata,
onPlace,
}: PlacementProps) {
const shipBoard = boards[`p${myIndex}_ships`]?.grid;
const placedCount = metadata[`p${myIndex}_placed`] ?? 0;
const [selectedShip, setSelectedShip] = useState<string | null>(null);
const [orientation, setOrientation] = useState<"h" | "v">("h");
const [hoverPos, setHoverPos] = useState<[number, number] | null>(null);
if (!shipBoard) return <div>Loading...</div>;
const remainingShips = fleet.slice(placedCount);
const current = remainingShips[0];
const shipName = current?.name ?? null;
const shipSize = current?.size ?? 0;
const canPlace = (r: number, c: number) => {
if (!shipName) return false;
// bounds
if (orientation === "h") {
if (c + shipSize > shipBoard[0].length) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[r][c + i] !== "") return false;
}
} else {
if (r + shipSize > shipBoard.length) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[r + i][c] !== "") return false;
}
}
return true;
};
const renderCell = (cell: string, r: number, c: number) => {
const hovered = hoverPos?.[0] === r && hoverPos?.[1] === c;
const placing = hovered && shipName;
let previewColor = "transparent";
if (placing) {
const valid = canPlace(r, c);
previewColor = valid ? "rgba(46, 204, 113, 0.4)" : "rgba(231, 76, 60, 0.4)";
}
return (
<motion.div
key={`${r}-${c}`}
onMouseEnter={() => setHoverPos([r, c])}
onMouseLeave={() => setHoverPos(null)}
onClick={() => {
if (shipName && canPlace(r, c)) {
onPlace(shipName, r, c, orientation);
}
}}
whileHover={{ scale: 1.03 }}
style={{
width: 40,
height: 40,
border: "1px solid #333",
background: cell === "S" ? "#2980b9" : previewColor,
cursor: shipName ? "pointer" : "default",
}}
/>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 10 }}>Place Your Fleet</h2>
{shipName ? (
<div style={{ marginBottom: 12 }}>
<strong>Placing:</strong> {shipName} ({shipSize})
</div>
) : (
<div style={{ marginBottom: 12, color: "#2ecc71" }}>
All ships placed waiting for opponent...
</div>
)}
{shipName && (
<div style={{ marginBottom: 12 }}>
<button
onClick={() => setOrientation(orientation === "h" ? "v" : "h")}
style={{
padding: "8px 14px",
background: "#111",
border: "1px solid #666",
borderRadius: 6,
color: "white",
}}
>
Orientation: {orientation === "h" ? "Horizontal" : "Vertical"}
</button>
</div>
)}
{/* BOARD GRID */}
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${shipBoard[0].length}, 40px)`,
gap: 4,
justifyContent: "center",
marginTop: 20,
}}
>
{shipBoard.map((row, r) =>
row.map((cell, c) => renderCell(cell, r, c))
)}
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
interface ShipGridProps {
shipBoard: string[][]; // current placed ships
shipName: string | null; // current ship being placed
shipSize: number | null;
onPlace: (ship: string, row: number, col: number, dir: "h" | "v") => void;
}
export default function PlacementGrid({
shipBoard,
shipName,
shipSize,
onPlace,
}: ShipGridProps) {
const [dir, setDir] = useState<"h" | "v">("h");
const [hoverR, setHoverR] = useState<number | null>(null);
const [hoverC, setHoverC] = useState<number | null>(null);
const rows = shipBoard.length;
const cols = shipBoard[0].length;
function isPreviewCell(r: number, c: number): boolean {
if (hoverR === null || hoverC === null || !shipSize) return false;
if (dir === "h") {
return r === hoverR && c >= hoverC && c < hoverC + shipSize;
} else {
return c === hoverC && r >= hoverR && r < hoverR + shipSize;
}
}
function isPreviewValid(): boolean {
if (hoverR === null || hoverC === null || !shipSize) return false;
if (dir === "h") {
if (hoverC + shipSize > cols) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[hoverR][hoverC + i] !== "") return false;
}
} else {
if (hoverR + shipSize > rows) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[hoverR + i][hoverC] !== "") return false;
}
}
return true;
}
function handleClick() {
if (shipName && shipSize && hoverR !== null && hoverC !== null) {
if (isPreviewValid()) {
onPlace(shipName, hoverR, hoverC, dir);
}
}
}
return (
<div style={{ textAlign: "center" }}>
{/* Ship rotation button */}
<button
onClick={() => setDir(dir === "h" ? "v" : "h")}
style={{
padding: "6px 12px",
marginBottom: "10px",
background: "#333",
color: "white",
borderRadius: 6,
cursor: "pointer",
}}
>
Rotate Ship ({dir.toUpperCase()})
</button>
{/* GRID */}
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 36px)`,
gap: "4px",
justifyContent: "center",
}}
onClick={handleClick}
>
{shipBoard.map((row, r) =>
row.map((cell, c) => {
const preview = isPreviewCell(r, c);
const valid = isPreviewValid();
let bg = "#0a0a0a";
if (cell === "S") {
bg = "#3498db"; // placed ship
} else if (preview) {
bg = valid ? "rgba(46, 204, 113, 0.6)" : "rgba(231, 76, 60, 0.6)";
}
return (
<motion.div
key={`${r}-${c}`}
whileHover={{ scale: 1.05 }}
onMouseEnter={() => {
setHoverR(r);
setHoverC(c);
}}
onMouseLeave={() => {
setHoverR(null);
setHoverC(null);
}}
style={{
width: 36,
height: 36,
border: "1px solid #333",
background: bg,
borderRadius: 4,
}}
/>
);
})
)}
</motion.div>
</div>
);
}

View File

@@ -1,36 +1,43 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import getHaiku from "./utils/haikus";
import { useNakama } from "../../providers/NakamaProvider";
import getHaiku from "../../utils/haikus";
import { PlayerModel } from "../../models/player";
interface BoardProps {
board: string[][];
boards: Record<string, { grid: string[][] }>;
turn: number;
winner: string | null;
players: string[];
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}
export default function Board({
board,
export default function TicTacToeBoard({
boards,
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 +49,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 {
@@ -53,6 +62,7 @@ export default function Board({
const nextLineIn = 3600;
const allLinesStay = 2400;
const allLinesFade = 1200;
const board = boards['tictactoe']?.grid ?? null;
useEffect(() => {
const totalTime = haiku.length * nextLineIn + allLinesStay + allLinesFade;
@@ -98,7 +108,9 @@ export default function Board({
{/* -------------------------
BOARD
-------------------------- */}
<motion.div
{!board && <div style={{ textAlign: "center", marginTop: "14px" }}>Loading...</div>}
{board && <motion.div
layout
style={{
display: "grid",
@@ -171,7 +183,7 @@ export default function Board({
);
})
)}
</motion.div>
</motion.div>}
{!winner && (
<div

View File

@@ -4,9 +4,9 @@ import {
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import { useNakama } from "./providers/NakamaProvider";
import { useNakama } from "../../providers/NakamaProvider";
export default function Leaderboard({
export default function TicTacToeLeaderboard({
intervalMs = 10000,
}: {
intervalMs?: number;

View File

@@ -1,14 +1,14 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import TicTacToe from './tictactoe/TicTacToe';
import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
import "./tictactoe/styles.css";
import App from './App';
import { NakamaProvider } from './providers/NakamaProvider';
import "./styles.css";
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<NakamaProvider>
<TicTacToe />
<App />
</NakamaProvider>,
);

6
src/models/player.ts Normal file
View File

@@ -0,0 +1,6 @@
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}

View File

@@ -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,8 @@ export interface NakamaContextType {
loginOrRegister(username: string): Promise<void>;
logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>;
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
exitMatchmaker(gameMetadata: GameMetadata): Promise<void>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
@@ -46,19 +52,27 @@ 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_SERVER_KEY,
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 gameMetadataRef = React.useRef<GameMetadata | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [socket, setSocket] = useState<Socket | null>(null);
const [matchmakerTicket, setMatchmakerTicket] = 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);
async function autoLogin() {
@@ -117,8 +131,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;
@@ -131,9 +147,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);
}
@@ -176,23 +192,44 @@ 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 };
setMatchmakerTicket(ticket.ticket);
return ticket.ticket;
}
async function exitMatchmaker(gameMetadata: GameMetadata) {
const socket = socketRef.current;
const game = gameMetadata.game;
const mode = gameMetadata.mode;
if (!socket) throw new Error("socket missing");
console.log(`[Nakama] Exiting Matchmaking... game="${game}" mode="${mode}"`);
if (matchmakerTicket) await socket.removeMatchmaker(matchmakerTicket);
setMatchmakerTicket(null);
}
// ----------------------------------------------------
// EXPLICIT MATCH JOIN
// ----------------------------------------------------
@@ -208,6 +245,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));
}
@@ -260,6 +298,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
loginOrRegister,
logout,
joinMatchmaker,
exitMatchmaker,
joinMatch,
sendMatchData,
onMatchData,

View File

@@ -1,55 +0,0 @@
import { Match } from "@heroiclabs/nakama-js";
interface MatchListProps {
matches: Match[];
}
export default function MatchList({ matches }: MatchListProps) {
if (!matches.length) return <p>No open matches</p>;
return (
<div style={{ marginTop: "20px" }}>
<h3>Open Matches</h3>
<table
style={{
width: "100%",
borderCollapse: "collapse",
marginTop: "10px",
}}
>
<thead>
<tr>
<th style={th}>Sr No.</th>
<th style={th}>Match ID</th>
<th style={th}>Label</th>
</tr>
</thead>
<tbody>
{matches
.filter(m => m.size ?? 0 > 0)
.map((m, index) => (
<tr key={m.match_id}>
<td style={td}>{index + 1}</td>
<td style={td}>{m.match_id}</td>
<td style={td}>{m.label ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const th: React.CSSProperties = {
textAlign: "left",
padding: "8px",
background: "#f2f2f2",
borderBottom: "1px solid #ccc",
};
const td: React.CSSProperties = {
padding: "8px",
borderBottom: "1px solid #eee",
};

View File

@@ -1,12 +0,0 @@
interface SquareProps {
value: string;
onClick: () => void;
}
export default function Square({ value, onClick } : SquareProps) {
return (
<button className="square" onClick={onClick}>
{value}
</button>
);
}

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

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