From ab9dd4268976b2bdbd225d88037685b9c77c0965 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 4 Dec 2025 18:56:48 +0530 Subject: [PATCH] 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 - gameMetadataRef: React.RefObject - 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. --- src/App.tsx | 2 +- src/games/battleship/props.ts | 15 +++ src/games/tictactoe/props.ts | 15 +++ src/interfaces/contexts.ts | 38 +++++++ src/interfaces/models.ts | 36 +++++++ src/interfaces/props.ts | 7 ++ src/interfaces/refs.ts | 15 +++ src/interfaces/states.ts | 11 ++ src/models/player.ts | 6 -- src/providers/NakamaProvider.tsx | 180 +++++++++++++++++-------------- 10 files changed, 239 insertions(+), 86 deletions(-) create mode 100644 src/games/battleship/props.ts create mode 100644 src/games/tictactoe/props.ts create mode 100644 src/interfaces/contexts.ts create mode 100644 src/interfaces/models.ts create mode 100644 src/interfaces/props.ts create mode 100644 src/interfaces/refs.ts create mode 100644 src/interfaces/states.ts delete mode 100644 src/models/player.ts diff --git a/src/App.tsx b/src/App.tsx index ff855ee..bc2edd2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; diff --git a/src/games/battleship/props.ts b/src/games/battleship/props.ts new file mode 100644 index 0000000..0329f71 --- /dev/null +++ b/src/games/battleship/props.ts @@ -0,0 +1,15 @@ +import { + Board, + PlayerModel, +} from '../../interfaces/models' + + +export interface BattleShipBoardProps { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean | null; + players: PlayerModel[]; + myUserId: string | null; + metadata: Record; +} diff --git a/src/games/tictactoe/props.ts b/src/games/tictactoe/props.ts new file mode 100644 index 0000000..bad6ef9 --- /dev/null +++ b/src/games/tictactoe/props.ts @@ -0,0 +1,15 @@ +import { + Board, + PlayerModel, +} from '../../interfaces/models' + + +export interface TicTacToeBoardProps { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean | null; + players: PlayerModel[]; + myUserId: string | null; + onCellClick: (row: number, col: number) => void; +} diff --git a/src/interfaces/contexts.ts b/src/interfaces/contexts.ts new file mode 100644 index 0000000..f072ea4 --- /dev/null +++ b/src/interfaces/contexts.ts @@ -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; + logout(): Promise; + + joinMatchmaker(gameMetadata: GameMetadata): Promise; + exitMatchmaker(gameMetadata: GameMetadata): Promise; + joinMatch(matchId: string): Promise; + + sendMatchData(matchId: string, op: number, data: object): void; + + onMatchData(cb: (msg: MatchDataMessage) => void): void; + + getLeaderboardTop(): Promise; + listOpenMatches(): Promise; +} diff --git a/src/interfaces/models.ts b/src/interfaces/models.ts new file mode 100644 index 0000000..5da685c --- /dev/null +++ b/src/interfaces/models.ts @@ -0,0 +1,36 @@ +export interface PlayerModel { + user_id: string; + username: string; + index: number; + metadata: Record; // e.g. { symbol: "X" } +} + +export interface MatchDataMessage { + opCode: number; + data: T; + userId: string | null; +} + +export interface Board { + grid: string[][]; +} + +export interface GameState { + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean; + players: PlayerModel[]; + metadata: Record; +} + +export interface GameMetadata { + game: string; + mode: string; +} + +export interface MatchDataMessage { + opCode: number; + data: T; + userId: string | null; +} diff --git a/src/interfaces/props.ts b/src/interfaces/props.ts new file mode 100644 index 0000000..6ad6962 --- /dev/null +++ b/src/interfaces/props.ts @@ -0,0 +1,7 @@ +import { + MatchDataMessage, +} from './models' + +export interface PlayerProps { + onMatchDataCallback: (msg:MatchDataMessage) => void; +} diff --git a/src/interfaces/refs.ts b/src/interfaces/refs.ts new file mode 100644 index 0000000..ad086fa --- /dev/null +++ b/src/interfaces/refs.ts @@ -0,0 +1,15 @@ +import React from "react"; + +import { + Socket +} from "@heroiclabs/nakama-js"; + +import { + GameMetadata, +} from './models' + + +export interface NakamaRefs { + socketRef: React.RefObject; + gameMetadataRef: React.RefObject; +} \ No newline at end of file diff --git a/src/interfaces/states.ts b/src/interfaces/states.ts new file mode 100644 index 0000000..aa144e8 --- /dev/null +++ b/src/interfaces/states.ts @@ -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; +} \ No newline at end of file diff --git a/src/models/player.ts b/src/models/player.ts deleted file mode 100644 index 17940cd..0000000 --- a/src/models/player.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface PlayerModel { - user_id: string; - username: string; - index: number; - metadata: Record; // e.g. { symbol: "X" } -} diff --git a/src/providers/NakamaProvider.tsx b/src/providers/NakamaProvider.tsx index a3e92c2..edff171 100644 --- a/src/providers/NakamaProvider.tsx +++ b/src/providers/NakamaProvider.tsx @@ -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; - logout(): Promise; - joinMatchmaker(gameMetadata: GameMetadata): Promise; - exitMatchmaker(gameMetadata: GameMetadata): Promise; - joinMatch(matchId: string): Promise; - - sendMatchData(matchId: string, op: number, data: object): void; - - onMatchData(cb: (msg: any) => void): void; - getLeaderboardTop(): Promise; - listOpenMatches(): Promise; -} - export const NakamaContext = createContext(null!); export function NakamaProvider({ children }: { children: React.ReactNode }) { @@ -68,13 +53,32 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { ) ); - const gameMetadataRef = React.useRef(null); - const [session, setSession] = useState(null); - const [socket, setSocket] = useState(null); - const [matchmakerTicket, setMatchmakerTicket] = useState(null); - const [matchId, setMatchId] = useState(null); - const socketRef = React.useRef(null); + // -------------------------------------- + // INTERNAL STATE (React state) + // -------------------------------------- + const [internal, setInternal] = useState({ + session: null, + socket: null, + matchId: null, + matchmakerTicket: null, + }); + // -------------------------------------- + // INTERNAL REFS (non-reactive, stable) + // -------------------------------------- + const refs: NakamaRefs = { + socketRef: useRef(null), + gameMetadataRef: useRef(null), + }; + + // Helpers to update internal state cleanly + function updateState(values: Partial) { + 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 { - if (!session) return []; + if (!internal.session) return []; return await client.listLeaderboardRecords( - session, + internal.session, "tictactoe", [], 10 // top 10 ); } + async function listOpenMatches(): Promise { - 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 (