9 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
17 changed files with 397 additions and 196 deletions

View File

@@ -1,57 +1,72 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider"; import { useNakama } from "./providers/NakamaProvider";
import Player from "./Player";
import { PlayerModel } from "./models/player";
import TicTacToeBoard from "./games/tictactoe/TicTacToeBoard"; import Player from "./Player";
import BattleShipBoard from "./games/battleship/BattleShipBoard"; 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() { export default function App() {
// setting up a 2D game boards // unified game state
const [boards, setBoards] = useState<Record<string, { grid: string[][] }>>({}); const [game, setGame] = useState<GameState>(INITIAL_GAME_STATE);
const [turn, setTurn] = useState<number>(0); const { onMatchData, matchId, session } = useNakama();
const [winner, setWinner] = useState<string | null>(null);
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(); 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() { function renderGameBoard() {
if (!matchId || !metadata?.game) return null; if (!matchId || !game.metadata?.game) return null;
switch (metadata.game) { switch (game.metadata.game) {
case "tictactoe": case "tictactoe":
return ( return (
<TicTacToeBoard <TicTacToeGame
boards={boards} {...ticTacToeProps}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}
/> />
); );
case "battleship": case "battleship":
return ( return (
<BattleShipBoard <BattleshipGame
boards={boards} {...battleshipProps}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
metadata={metadata}
/> />
); );
default: default:
return <div>Unknown game: {metadata.game}</div>; return <div>Unknown game: {game.metadata.game}</div>;
} }
} }
// ------------------------------------------ // ------------------------------------------
// MATCH DATA CALLBACK (from Player component) // MATCH DATA CALLBACK (from Player component)
// ------------------------------------------ // ------------------------------------------
@@ -62,23 +77,21 @@ export default function App() {
const state = msg.data; const state = msg.data;
console.log("Match state:", state); console.log("Match state:", state);
setBoards(state.boards); setGame({
setTurn(state.turn); boards: state.boards,
setGameOver(state.game_over); turn: state.turn,
if (state.winner >= 0) { gameOver: state.game_over,
setWinner(state.players[state.winner].username); winner:
// } else if (state.game_over) { state.winner >= 0 ? state.players[state.winner].username : null,
// // Game ended but winner = -1 → draw players: state.players ?? [],
// setWinner("draw"); metadata: state.metadata ?? {},
} else { });
// Ongoing game, no winner
setWinner(null);
}
setPlayers(state.players || []);
setMetadata(state.metadata || {});
} }
} }
// ---------------------------------------------------
// EFFECTS
// ---------------------------------------------------
useEffect(() => { useEffect(() => {
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { return () => {
@@ -90,15 +103,9 @@ export default function App() {
onMatchData(onMatchDataCallback); onMatchData(onMatchDataCallback);
}, [onMatchData]); }, [onMatchData]);
// ------------------------------------------ // ---------------------------------------------------
// SEND A MOVE // UI LAYOUT
// ------------------------------------------ // ---------------------------------------------------
function handleCellClick(row: number, col: number) {
if (!matchId) return;
sendMatchData(matchId, 1, {data: {row, col}});
}
return ( return (
<div <div
style={{ style={{

View File

@@ -1,12 +1,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider"; import { useNakama } from "./providers/NakamaProvider";
import { PlayerProps } from "./interfaces/props";
export default function Player({ export default function Player({
onMatchDataCallback, onMatchDataCallback,
}: { }: PlayerProps) {
onMatchDataCallback: (msg: any) => void;
}) {
const { const {
session, session,
matchId, matchId,

View File

@@ -1,20 +1,15 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider"; import { useNakama } from "../../providers/NakamaProvider";
import { PlayerModel } from "../../models/player";
import PlacementGrid from "./placement/PlacementGrid"; import PlacementGrid from "./placement/PlacementGrid";
import ShotGrid from "./battle/ShotGrid"; import ShotGrid from "./battle/ShotGrid";
import { BattleshipGameProps } from "./props";
interface BattleBoardProps { import { BattleshipPayload } from "./models";
boards: Record<string, { grid: string[][] }>; import {
players: PlayerModel[]; placePayload,
myUserId: string | null; shootPayload,
turn: number; } from "./utils";
winner: string | null;
gameOver: boolean | null;
metadata: Record<string, any>;
}
const Fleet: Record<string, number> = { const Fleet: Record<string, number> = {
carrier: 5, carrier: 5,
@@ -25,7 +20,7 @@ const Fleet: Record<string, number> = {
}; };
const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"]; const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"];
export default function BattleShipBoard({ export default function BattleshipGame({
boards, boards,
players, players,
myUserId, myUserId,
@@ -33,7 +28,7 @@ export default function BattleShipBoard({
winner, winner,
gameOver, gameOver,
metadata, metadata,
}: BattleBoardProps) { }: BattleshipGameProps) {
const { sendMatchData, matchId } = useNakama(); const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex((p) => p.user_id === myUserId); const myIndex = players.findIndex((p) => p.user_id === myUserId);
@@ -50,28 +45,10 @@ export default function BattleShipBoard({
const nextShip = FLEET_ORDER[placed] || null; const nextShip = FLEET_ORDER[placed] || null;
const nextShipSize = nextShip ? Fleet[nextShip] : null; const nextShipSize = nextShip ? Fleet[nextShip] : null;
// ------------------- PLACE SHIP ------------------- function handleMove(matchPayload: BattleshipPayload) {
function handlePlace(ship: string, r: number, c: number, dir: "h" | "v") { if (!matchId) return;
sendMatchData(matchId!, 1, {
action: "place",
data: {
ship: ship,
row: r,
col: c,
dir,
}
});
}
// ------------------- SHOOT ------------------- sendMatchData(matchId!, 1, matchPayload);
function handleShoot(r: number, c: number) {
sendMatchData(matchId!, 1, {
action: "shoot",
data: {
row: r,
col: c,
}
});
} }
// ------------------- STATUS LABEL ------------------- // ------------------- STATUS LABEL -------------------
@@ -100,7 +77,11 @@ export default function BattleShipBoard({
shipBoard={myShips} shipBoard={myShips}
shipName={nextShip} shipName={nextShip}
shipSize={nextShipSize} shipSize={nextShipSize}
onPlace={handlePlace} onPlace={(
s,r,c,d
) => handleMove(
placePayload(s,r,c,d)
)}
/> />
)} )}
@@ -113,7 +94,11 @@ export default function BattleShipBoard({
grid={myShots} grid={myShots}
isMyTurn={isMyTurn} isMyTurn={isMyTurn}
gameOver={!!gameOver} gameOver={!!gameOver}
onShoot={handleShoot} onShoot={(
r,c
) => handleMove(
shootPayload(r,c)
)}
/> />
<h3 style={{ marginTop: "18px" }}>Your Ships</h3> <h3 style={{ marginTop: "18px" }}>Your Ships</h3>

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

@@ -2,32 +2,23 @@ import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider"; import { useNakama } from "../../providers/NakamaProvider";
import getHaiku from "../../utils/haikus"; import getHaiku from "../../utils/haikus";
import { PlayerModel } from "../../models/player";
interface BoardProps { import { TicTacToeGameProps } from "./props";
boards: Record<string, { grid: string[][] }>; import { TicTacToePayload } from "./models";
turn: number; import { movePayload } from "./utils";
winner: string | null;
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}
export default function TicTacToeBoard({ export default function TicTacToeGame({
boards, boards,
turn, turn,
winner, winner,
gameOver, gameOver,
players, players,
myUserId, myUserId,
onCellClick, }: TicTacToeGameProps) {
}: BoardProps) { const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex(p => p.user_id === myUserId); const myIndex = players.findIndex(p => p.user_id === myUserId);
const gameReady = players.length === 2; const gameReady = players.length === 2;
const {
matchId
} = useNakama();
const mySymbol = const mySymbol =
myIndex !== null && players[myIndex] myIndex !== null && players[myIndex]
@@ -76,6 +67,12 @@ export default function TicTacToeBoard({
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [haikuIndex]); }, [haikuIndex]);
function handleMove(matchPayload: TicTacToePayload) {
if (!matchId) return;
sendMatchData(matchId!, 1, matchPayload);
}
return ( return (
<> <>
{matchId && ( {matchId && (
@@ -141,7 +138,9 @@ export default function TicTacToeBoard({
: {} : {}
} }
whileTap={!disabled ? { scale: 0.85 } : {}} whileTap={!disabled ? { scale: 0.85 } : {}}
onClick={() => !disabled && onCellClick(rIdx, cIdx)} onClick={() => !disabled && handleMove(
movePayload(rIdx, cIdx)
)}
style={{ style={{
width: "80px", width: "80px",
height: "80px", height: "80px",

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

View File

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