3 Commits

Author SHA1 Message Date
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
10 changed files with 239 additions and 86 deletions

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import Player from "./Player";
import { PlayerModel } from "./models/player";
import { PlayerModel } from "./interfaces/models";
import TicTacToeBoard from "./games/tictactoe/TicTacToeBoard";
import BattleShipBoard from "./games/battleship/BattleShipBoard";

View File

@@ -0,0 +1,15 @@
import {
Board,
PlayerModel,
} from '../../interfaces/models'
export interface BattleShipBoardProps {
boards: Record<string, Board>;
turn: number;
winner: string | null;
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
metadata: Record<string, any>;
}

View File

@@ -0,0 +1,15 @@
import {
Board,
PlayerModel,
} from '../../interfaces/models'
export interface TicTacToeBoardProps {
boards: Record<string, Board>;
turn: number;
winner: string | null;
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}

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 {
GameMetadata,
MatchDataMessage,
} 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: GameMetadata): Promise<string>;
exitMatchmaker(gameMetadata: GameMetadata): Promise<void>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: MatchDataMessage) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}

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

@@ -0,0 +1,36 @@
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
export interface MatchDataMessage<T = any> {
opCode: number;
data: T;
userId: string | null;
}
export interface Board {
grid: string[][];
}
export interface GameState {
boards: Record<string, Board>;
turn: number;
winner: string | null;
gameOver: boolean;
players: PlayerModel[];
metadata: Record<string, any>;
}
export interface GameMetadata {
game: string;
mode: string;
}
export interface MatchDataMessage<T = any> {
opCode: number;
data: T;
userId: string | null;
}

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

@@ -0,0 +1,7 @@
import {
MatchDataMessage,
} from './models'
export interface PlayerProps {
onMatchDataCallback: (msg:MatchDataMessage) => void;
}

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

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

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

@@ -0,0 +1,11 @@
import {
Session,
Socket
} from "@heroiclabs/nakama-js";
export interface NakamaProviderState {
session: Session | null;
socket: Socket | null;
matchId: string | null;
matchmakerTicket: string | null;
}

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 {
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 { GameMetadata, MatchDataMessage } from "../interfaces/models";
function getOrCreateDeviceId(): string {
const key = "nakama.deviceId";
@@ -25,30 +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>;
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 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);
const [socket, setSocket] = useState<Socket | null>(null);
const [matchmakerTicket, setMatchmakerTicket] = useState<string | 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<GameMetadata | 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();
@@ -129,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");
@@ -147,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);
}
@@ -162,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);
@@ -170,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");
}
@@ -193,11 +203,10 @@ 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");
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");
}
@@ -213,30 +222,35 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
{ game, mode }
);
gameMetadataRef.current = { game, mode };
setMatchmakerTicket(ticket.ticket);
refs.gameMetadataRef.current = { game, mode };
updateState({ matchmakerTicket: 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");
async function exitMatchmaker() {
const socket = refs.socketRef.current;
const { matchmakerTicket } = internal;
console.log(`[Nakama] Exiting Matchmaking... game="${game}" mode="${mode}"`);
if (matchmakerTicket) await socket.removeMatchmaker(matchmakerTicket);
setMatchmakerTicket(null);
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);
}
@@ -244,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: MatchDataMessage) => 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,
@@ -265,36 +280,43 @@ 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,