19 Commits

Author SHA1 Message Date
b00519347a cleanup 2025-12-04 19:57:06 +05:30
8436cdbcdd refactor(game): unify move handling using typed payloads and remove UI-driven handlers
- Removed onCellClick from TicTacToeGameProps and migrated move sending inside TicTacToeGame
- Updated TicTacToeGame to:
  - import TicTacToePayload
  - use movePayload() builder
  - send moves using handleMove() with matchId + sendMatchData
  - remove old matchId destructuring duplication

- Updated BattleshipGame to:
  - import BattleshipPayload
  - use placePayload() and shootPayload() helpers
  - collapse place and shoot handlers into a single handleMove()
  - send typed payloads instead of raw objects

- Updated App.tsx:
  - Removed handleCellClick and no longer pass onCellClick down
  - Created typed ticTacToeProps and battleshipProps without UI callbacks
  - Cleaned unused state and simplified board rendering
  - Use {...commonProps} to propagate shared game state

- Updated props:
  - Removed TicTacToeGameProps.onCellClick
  - BattleshipGameProps continues to extend GameProps

- Removed duplicate MatchDataModel definition from interfaces/models
- Fixed imports to use revised models and payload types

This refactor completes the transition from UI-triggered handlers to
typed action payloads per game, significantly improving type safety,
consistency, and separation of concerns.
2025-12-04 19:56:46 +05:30
135fdd332d refactor(types): rename interfaces with *Model suffix and update references across codebase
- Renamed GameMetadata → GameMetadataModel for naming consistency
- Renamed Board → BoardModel
- Renamed MatchDataMessage → MatchDataModel (duplicate name removed)
- Updated all imports and references in:
  - NakamaProvider
  - contexts.ts
  - refs.ts
  - states.ts
  - Player.tsx
  - props and models files
- Updated GameState to use BoardModel instead of Board
- Updated NakamaContextType to use GameMetadataModel and MatchDataModel
- Updated NakamaRefs to store gameMetadataRef: RefObject<GameMetadataModel>
- Updated joinMatchmaker() and exitMatchmaker() signatures
- Updated onMatchData() to emit MatchDataModel
- Updated Player component to use PlayerProps type instead of inline typing

This commit standardizes naming conventions by ensuring all schema/interface
definitions follow the *Model naming pattern, improving clarity and type consistency
across the project.
2025-12-04 19:29:35 +05:30
8dc41fca2c using correct props instead of internal props for TicTacToeGame.tsx and BattleshipGame.tsx 2025-12-04 19:24:14 +05:30
fc7cb8efb6 renamed BattleShipGame.tsx to BattleshipGame.tsx to match props name. using props interface for both instead of using commonProps 2025-12-04 19:22:07 +05:30
06bdc92190 refactor(game): unify GameState, standardize board props, and rename game components
- Replaced multiple App-level state fields with unified GameState
- Added INITIAL_GAME_STATE and migrated App.tsx to use single game state
- Introduced GameProps as shared base props for all turn-based board games
- Created TicTacToeGameProps and BattleshipGameProps extending GameProps
- Updated TicTacToe and Battleship components to use new props
- Replaced verbose prop passing with spread {...commonProps}
- Updated renderGameBoard to use game.metadata consistently
- Renamed TicTacToeBoard -> TicTacToeGame for clarity
- Renamed BattleShipBoard -> BattleShipGame for naming consistency
- Updated all import paths to reflect new component names
- Replaced MatchDataMessage with MatchDataModel
- Moved GameState definition from models.ts to interfaces/states.ts
- Removed old board-specific prop structures and per-field state management
- Increased type safety and reduced duplication across the codebase

This commit consolidates game state flow, introduces a clean component props
architecture, and standardizes naming convention
2025-12-04 19:16:20 +05:30
650d7b7ed6 Revert "refactored PlayerModel to Player"
This reverts commit 68c2e3a8d9.
2025-12-04 18:59:06 +05:30
68c2e3a8d9 refactored PlayerModel to Player 2025-12-04 18:58:37 +05:30
ab9dd42689 refactor: separate Nakama provider concerns into context, refs, and state modules
- Extracted context contract to `contexts.ts` (NakamaContextType)
- Added strongly typed internal provider refs in `refs.ts`
  - socketRef: React.RefObject<Socket | null>
  - gameMetadataRef: React.RefObject<GameMetadata | null>
- Added `NakamaProviderState` in `states.ts` for React-managed provider state
  - session, socket, matchId, matchmakerTicket
- Refactored NakamaProvider to use new modular structure
  - Replaced scattered useState/useRef with structured internal state + refs
  - Updated onMatchData to use MatchDataMessage model
  - Replaced deprecated MutableRefObject typing with RefObject
  - Cleaned update patterns using `updateState` helper
- Updated imports to use new models and context structure
- Improved separation of responsibilities:
  - models = pure domain types
  - context = exposed provider API
  - refs = internal mutable runtime refs
  - state = provider-managed reactive state
- Ensured all Nakama provider functions fully typed and consistent with TS

This refactor improves clarity, type safety, and maintainability for the
Nakama real-time multiplayer provider.
2025-12-04 18:56:48 +05:30
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
30 changed files with 1433 additions and 320 deletions

165
src/App.tsx Normal file
View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import Player from "./Player";
import TicTacToeGame from "./games/tictactoe/TicTacToeGame";
import { TicTacToeGameProps } from "./games/tictactoe/props";
import BattleshipGame from "./games/battleship/BattleshipGame"
import { BattleshipGameProps } from "./games/battleship/props";
import { GameState } from "./interfaces/states";
import { GameProps } from "./interfaces/props";
const INITIAL_GAME_STATE: GameState = {
boards: {},
turn: 0,
winner: null,
gameOver: false,
players: [],
metadata: {},
};
export default function App() {
// unified game state
const [game, setGame] = useState<GameState>(INITIAL_GAME_STATE);
const { onMatchData, matchId, session } = useNakama();
const commonProps: GameProps = {
boards: game.boards,
turn: game.turn,
winner: game.winner,
gameOver: game.gameOver,
players: game.players,
myUserId: session?.user_id ?? null,
};
const ticTacToeProps: TicTacToeGameProps = {
...commonProps,
};
const battleshipProps: BattleshipGameProps = {
...commonProps,
metadata: game.metadata,
};
// ---------------------------------------------------
// RENDER GAME BOARD
// ---------------------------------------------------
function renderGameBoard() {
if (!matchId || !game.metadata?.game) return null;
switch (game.metadata.game) {
case "tictactoe":
return (
<TicTacToeGame
{...ticTacToeProps}
/>
);
case "battleship":
return (
<BattleshipGame
{...battleshipProps}
/>
);
default:
return <div>Unknown game: {game.metadata.game}</div>;
}
}
// ------------------------------------------
// MATCH DATA CALLBACK (from Player component)
// ------------------------------------------
function onMatchDataCallback(msg: { opCode: number; data: any }) {
console.log("[Match Data]", msg);
if (msg.opCode === 2) {
const state = msg.data;
console.log("Match state:", state);
setGame({
boards: state.boards,
turn: state.turn,
gameOver: state.game_over,
winner:
state.winner >= 0 ? state.players[state.winner].username : null,
players: state.players ?? [],
metadata: state.metadata ?? {},
});
}
}
// ---------------------------------------------------
// EFFECTS
// ---------------------------------------------------
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => {
onMatchData(onMatchDataCallback);
}, [onMatchData]);
// ---------------------------------------------------
// UI LAYOUT
// ---------------------------------------------------
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
background: "#060606",
color: "white",
overflow: "hidden",
}}
>
{/* ---------------- HEADER (always fixed at top) ---------------- */}
<header
style={{
padding: "16px 20px",
background: "rgba(255,255,255,0.04)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(6px)",
textAlign: "center",
fontSize: "26px",
fontWeight: 700,
letterSpacing: "1px",
}}
>
Games
</header>
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
flex: 1,
overflowY: "auto",
padding: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Player onMatchDataCallback={onMatchDataCallback} />
<div
style={{
padding: "20px",
background: "rgba(255,255,255,0.03)",
borderRadius: "20px",
boxShadow: "0 6px 20px rgba(0,0,0,0.4)",
minWidth: "300px",
}}
>
{renderGameBoard()}
</div>
</motion.div>
</div>
);
}

View File

@@ -1,13 +1,11 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Leaderboard from "./Leaderboard";
import { useNakama } from "./providers/NakamaProvider";
import { PlayerProps } from "./interfaces/props";
export default function Player({
onMatchDataCallback,
}: {
onMatchDataCallback: (msg: any) => void;
}) {
}: PlayerProps) {
const {
session,
matchId,
@@ -15,11 +13,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,14 +37,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({
game: 'tictactoe',
mode: selectedMode,
});
const ticket = await joinMatchmaker(gameMetadata);
console.log("Queued:", ticket);
} catch (err) {
console.error("Matchmaking failed:", err);
@@ -52,10 +56,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(() => {
@@ -150,9 +161,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",
@@ -163,14 +174,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",
@@ -219,7 +251,10 @@ export default function Player({
{/* Cancel button */}
<button
onClick={cancelQueue}
onClick={() => cancelQueue(
selectedGame,
selectedMode,
)}
style={{
marginTop: "10px",
padding: "6px 12px",
@@ -238,7 +273,6 @@ export default function Player({
)}
<div style={{ marginTop: "24px" }}>
<Leaderboard />
</div>
<motion.button

View File

@@ -0,0 +1,132 @@
import React, { useMemo } from "react";
import { motion } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider";
import PlacementGrid from "./placement/PlacementGrid";
import ShotGrid from "./battle/ShotGrid";
import { BattleshipGameProps } from "./props";
import { BattleshipPayload } from "./models";
import {
placePayload,
shootPayload,
} from "./utils";
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 BattleshipGame({
boards,
players,
myUserId,
turn,
winner,
gameOver,
metadata,
}: BattleshipGameProps) {
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;
function handleMove(matchPayload: BattleshipPayload) {
if (!matchId) return;
sendMatchData(matchId!, 1, matchPayload);
}
// ------------------- 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={(
s,r,c,d
) => handleMove(
placePayload(s,r,c,d)
)}
/>
)}
{/* ---------------- PHASE 2: BATTLE ---------------- */}
{phase === "battle" && (
<>
<h3>Your Shots</h3>
<ShotGrid
grid={myShots}
isMyTurn={isMyTurn}
gameOver={!!gameOver}
onShoot={(
r,c
) => handleMove(
shootPayload(r,c)
)}
/>
<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,15 @@
import {
MatchDataModel,
} from '../../interfaces/models'
export interface BattleshipPayload {
action: "place" | "shoot"; // extend as needed
data: {
ship?: string; // only for placement
row: number;
col: number;
dir?: "h" | "v";
};
}
export type BattleshipMatchDataModel = MatchDataModel<BattleshipPayload>;

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

@@ -0,0 +1,8 @@
import {
GameProps,
} from '../../interfaces/props'
export interface BattleshipGameProps extends GameProps {
metadata: Record<string, any>;
}

View File

@@ -0,0 +1,22 @@
import {
BattleshipPayload
} from "./models";
export function placePayload(
ship: string,
row: number,
col: number,
dir: "h" | "v"
): BattleshipPayload {
return {
action: "place",
data: { ship, row, col, dir }
};
}
export function shootPayload(row: number, col: number): BattleshipPayload {
return {
action: "shoot",
data: { row, col }
};
}

View File

@@ -1,39 +1,24 @@
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";
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
import { TicTacToeGameProps } from "./props";
import { TicTacToePayload } from "./models";
import { movePayload } from "./utils";
interface BoardProps {
board: string[][];
turn: number;
winner: string | null;
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}
export default function Board({
board,
export default function TicTacToeGame({
boards,
turn,
winner,
gameOver,
players,
myUserId,
onCellClick,
}: BoardProps) {
}: TicTacToeGameProps) {
const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex(p => p.user_id === myUserId);
const gameReady = players.length === 2;
const {
matchId
} = useNakama();
const mySymbol =
myIndex !== null && players[myIndex]
@@ -68,6 +53,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;
@@ -81,6 +67,12 @@ export default function Board({
return () => clearTimeout(timer);
}, [haikuIndex]);
function handleMove(matchPayload: TicTacToePayload) {
if (!matchId) return;
sendMatchData(matchId!, 1, matchPayload);
}
return (
<>
{matchId && (
@@ -113,7 +105,9 @@ export default function Board({
{/* -------------------------
BOARD
-------------------------- */}
<motion.div
{!board && <div style={{ textAlign: "center", marginTop: "14px" }}>Loading...</div>}
{board && <motion.div
layout
style={{
display: "grid",
@@ -144,7 +138,9 @@ export default function Board({
: {}
}
whileTap={!disabled ? { scale: 0.85 } : {}}
onClick={() => !disabled && onCellClick(rIdx, cIdx)}
onClick={() => !disabled && handleMove(
movePayload(rIdx, cIdx)
)}
style={{
width: "80px",
height: "80px",
@@ -186,7 +182,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

@@ -0,0 +1,11 @@
import {
MatchDataModel,
} from '../../interfaces/models'
export interface TicTacToePayload {
data: {
row: number;
col: number;
};
}
export type TicTacToeMatchDataModel = MatchDataModel<TicTacToePayload>;

View File

@@ -0,0 +1,8 @@
import {
GameProps,
} from '../../interfaces/props'
export interface TicTacToeGameProps extends GameProps {
// metadata: Record<string, any>;
}

View File

@@ -0,0 +1,12 @@
import {
TicTacToePayload
} from "./models";
export function movePayload(
row: number,
col: number,
): TicTacToePayload {
return {
data: { row, col }
};
}

View File

@@ -0,0 +1,38 @@
import {
Client,
Session,
Socket,
} from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import {
GameMetadataModel,
MatchDataModel,
} from './models'
export interface NakamaContextType {
client: Client;
socket: Socket | null;
session: Session | null;
matchId: string | null;
loginOrRegister(username?: string): Promise<void>;
logout(): Promise<void>;
joinMatchmaker(gameMetadata: GameMetadataModel): Promise<string>;
exitMatchmaker(gameMetadata: GameMetadataModel): Promise<void>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: MatchDataModel) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}

21
src/interfaces/models.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
export interface MatchDataModel<T = any> {
opCode: number;
data: T;
userId: string | null;
}
export interface BoardModel {
grid: string[][];
}
export interface GameMetadataModel {
game: string;
mode: string;
}

19
src/interfaces/props.ts Normal file
View File

@@ -0,0 +1,19 @@
import {
MatchDataModel,
} from './models'
import {
GameState
} from "./states";
export interface PlayerProps {
onMatchDataCallback: (msg:MatchDataModel) => void;
}
export interface GameProps
extends Pick<
GameState,
"boards" | "turn" | "winner" | "gameOver" | "players"
> {
myUserId: string | null;
}

15
src/interfaces/refs.ts Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import {
Socket
} from "@heroiclabs/nakama-js";
import {
GameMetadataModel,
} from './models'
export interface NakamaRefs {
socketRef: React.RefObject<Socket | null>;
gameMetadataRef: React.RefObject<GameMetadataModel | null>;
}

26
src/interfaces/states.ts Normal file
View File

@@ -0,0 +1,26 @@
import {
Session,
Socket
} from "@heroiclabs/nakama-js";
import {
BoardModel,
PlayerModel,
} from "./models"
export interface NakamaProviderState {
session: Session | null;
socket: Socket | null;
matchId: string | null;
matchmakerTicket: string | null;
}
export interface GameState {
boards: Record<string, BoardModel>;
turn: number;
winner: string | null;
gameOver: boolean;
players: PlayerModel[];
metadata: Record<string, any>;
}

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>,
);

View File

@@ -1,6 +1,12 @@
import React, {
createContext,
useContext,
useState,
useRef
} from "react";
import {
Client,
Session,
Socket,
MatchmakerTicket,
MatchData,
@@ -13,7 +19,10 @@ import {
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import React, { createContext, useContext, useState } from "react";
import { NakamaContextType } from "../interfaces/contexts";
import { NakamaRefs } from "../interfaces/refs";
import { NakamaProviderState } from "../interfaces/states";
import { GameMetadataModel, MatchDataModel } from "../interfaces/models";
function getOrCreateDeviceId(): string {
const key = "nakama.deviceId";
@@ -25,29 +34,6 @@ function getOrCreateDeviceId(): string {
return id;
}
type GameMetadata = {
game: string;
mode: string;
};
export interface NakamaContextType {
client: Client;
socket: Socket | null;
session: Session | null;
matchId: string | null;
loginOrRegister(username: string): Promise<void>;
logout(): Promise<void>;
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: any) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}
export const NakamaContext = createContext<NakamaContextType>(null!);
export function NakamaProvider({ children }: { children: React.ReactNode }) {
@@ -67,12 +53,32 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
)
);
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 socketRef = React.useRef<Socket | null>(null);
// --------------------------------------
// INTERNAL STATE (React state)
// --------------------------------------
const [internal, setInternal] = useState<NakamaProviderState>({
session: null,
socket: null,
matchId: null,
matchmakerTicket: null,
});
// --------------------------------------
// INTERNAL REFS (non-reactive, stable)
// --------------------------------------
const refs: NakamaRefs = {
socketRef: useRef<Socket | null>(null),
gameMetadataRef: useRef<GameMetadataModel | null>(null),
};
// Helpers to update internal state cleanly
function updateState(values: Partial<NakamaProviderState>) {
setInternal(prev => ({ ...prev, ...values }));
}
// ---------------------------------------
// LOGIN FLOW
// ---------------------------------------
async function autoLogin() {
const deviceId = getOrCreateDeviceId();
@@ -127,15 +133,14 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
async function loginOrRegister(username?: string) {
// authenticate user
const newSession = await getSession(username);
setSession(newSession);
const s = client.createSocket(
import.meta.env.VITE_WS_SSL === "true",
undefined
);
updateState({ session: newSession });
const s = client.createSocket(import.meta.env.VITE_WS_SSL === "true");
await s.connect(newSession, true);
setSocket(s);
socketRef.current = s;
updateState({ socket: s });
refs.socketRef.current = s;
console.log("[Nakama] WebSocket connected");
@@ -145,9 +150,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
if (!matched.match_id) {
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
if (gameMetadataRef.current) {
if (refs.gameMetadataRef.current) {
try {
await joinMatchmaker(gameMetadataRef.current);
await joinMatchmaker(refs.gameMetadataRef.current);
} catch (e) {
console.error("[Nakama] Requeue failed:", e);
}
@@ -160,7 +165,8 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
console.log("[Nakama] MATCHED:", matched);
try {
await s.joinMatch(matched.match_id);
setMatchId(matched.match_id);
updateState({ matchId: matched.match_id });
console.log("[Nakama] Auto-joined match:", matched.match_id);
} catch (err) {
console.error("[Nakama] Failed to join match:", err);
@@ -168,21 +174,27 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
};
}
// ---------------------------------------
// LOGOUT
// ---------------------------------------
async function logout() {
try {
// 1) Disconnect socket if present
if (socketRef.current) {
socketRef.current.disconnect(true);
if (refs.socketRef.current) {
refs.socketRef.current.disconnect(true);
console.log("[Nakama] WebSocket disconnected");
}
} catch (err) {
console.warn("[Nakama] Error while disconnecting socket:", err);
}
// 2) Clear state
setSocket(null);
socketRef.current = null;
setSession(null);
updateState({
session: null,
socket: null,
matchId: null,
matchmakerTicket: null,
});
refs.socketRef.current = null;
console.log("[Nakama] Clean logout completed");
}
@@ -190,12 +202,11 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// ----------------------------------------------------
// MATCHMAKING
// ----------------------------------------------------
async function joinMatchmaker(gameMetadata: GameMetadata) {
const socket = socketRef.current;
const game = gameMetadata.game;
const mode = gameMetadata.mode;
if (!socket) throw new Error("socket missing");
async function joinMatchmaker(gameMetadata: GameMetadataModel) {
const socket = refs.socketRef.current;
if (!socket) throw new Error("Socket missing");
const { game, mode } = gameMetadata;
if (!game || game.trim() === "") {
throw new Error("Matchmaking requires a game name");
}
@@ -211,18 +222,35 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
{ game, mode }
);
gameMetadataRef.current = { game, mode };
refs.gameMetadataRef.current = { game, mode };
updateState({ matchmakerTicket: ticket.ticket });
return ticket.ticket;
}
async function exitMatchmaker() {
const socket = refs.socketRef.current;
const { matchmakerTicket } = internal;
if (!socket) throw new Error("Socket missing");
if (matchmakerTicket) {
await socket.removeMatchmaker(matchmakerTicket);
}
updateState({ matchmakerTicket: null });
}
// ----------------------------------------------------
// EXPLICIT MATCH JOIN
// ----------------------------------------------------
async function joinMatch(id: string) {
if (!socket) throw new Error("socket missing");
await socket.joinMatch(id);
setMatchId(id);
if (!internal.socket) throw new Error("Socket missing");
await internal.socket.joinMatch(id);
updateState({ matchId: id });
console.log("[Nakama] Joined match", id);
}
@@ -230,18 +258,19 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// MATCH STATE SEND
// ----------------------------------------------------
function sendMatchData(matchId: string, op: number, data: object) {
if (!socket) return;
if (!internal.socket) return;
console.log("[Nakama] Sending match state:", matchId, op, data);
socket.sendMatchState(matchId, op, JSON.stringify(data));
internal.socket.sendMatchState(matchId, op, JSON.stringify(data));
}
// ----------------------------------------------------
// MATCH DATA LISTENER
// ----------------------------------------------------
function onMatchData(cb: (msg: any) => void) {
if (!socket) return;
function onMatchData(cb: (msg: MatchDataModel) => void) {
if (!internal.socket) return;
socket.onmatchdata = (m: MatchData) => {
internal.socket.onmatchdata = (m: MatchData) => {
const decoded = JSON.parse(new TextDecoder().decode(m.data));
cb({
opCode: m.op_code,
@@ -251,39 +280,47 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
};
}
// ---------------------------------------
// LEADERBOARD + LIST MATCHES
// ---------------------------------------
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
if (!session) return [];
if (!internal.session) return [];
return await client.listLeaderboardRecords(
session,
internal.session,
"tictactoe",
[],
10 // top 10
);
}
async function listOpenMatches(): Promise<ApiMatch[]> {
if (!session) {
if (!internal.session) {
console.warn("[Nakama] listOpenMatches called before login");
return [];
}
const result = await client.listMatches(session, 10);
const result = await client.listMatches(internal.session, 10);
console.log("[Nakama] Open matches:", result.matches);
return result.matches ?? [];
}
// ---------------------------------------
// PROVIDER VALUE
// ---------------------------------------
return (
<NakamaContext.Provider
value={{
client,
session,
socket,
matchId,
session: internal.session,
socket: internal.socket,
matchId: internal.matchId,
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>
);
}

View File

@@ -1,131 +0,0 @@
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[][]>([
["", "", ""],
["", "", ""],
["", "", ""]
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
const [gameOver, setGameOver] = useState<boolean | null>(null);
const [players, setPlayers] = useState<PlayerModel[]>([]);
const { sendMatchData, onMatchData, matchId, session } = useNakama();
// ------------------------------------------
// MATCH DATA CALLBACK (from Player component)
// ------------------------------------------
function onMatchDataCallback(msg: { opCode: number; data: any }) {
console.log("[Match Data]", msg);
if (msg.opCode === 2) {
const state = msg.data;
console.log("Match state:", state);
setBoard(state.board.grid);
setTurn(state.turn);
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 || []);
}
}
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => {
onMatchData(onMatchDataCallback);
}, [onMatchData]);
// ------------------------------------------
// SEND A MOVE
// ------------------------------------------
function handleCellClick(row: number, col: number) {
if (!matchId) return;
sendMatchData(matchId, 1, {data: {row, col}});
}
return (
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
background: "#060606",
color: "white",
overflow: "hidden",
}}
>
{/* ---------------- HEADER (always fixed at top) ---------------- */}
<header
style={{
padding: "16px 20px",
background: "rgba(255,255,255,0.04)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(6px)",
textAlign: "center",
fontSize: "26px",
fontWeight: 700,
letterSpacing: "1px",
}}
>
Tic Tac Toe
</header>
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
flex: 1,
overflowY: "auto",
padding: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Player onMatchDataCallback={onMatchDataCallback} />
<div
style={{
padding: "20px",
background: "rgba(255,255,255,0.03)",
borderRadius: "20px",
boxShadow: "0 6px 20px rgba(0,0,0,0.4)",
minWidth: "300px",
}}
>
<Board
board={board}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}
/>
</div>
</motion.div>
</div>
);
}