From fa02e8b4e40c8c8ac58bb63f3d0edb55d205bb52 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 1 Dec 2025 18:12:18 +0530 Subject: [PATCH] feat: update UI & Nakama provider for multi-game support and new match state format Add PlayerModel interface and switch board/player logic to full player objects Update matchmaking to require { game, mode } metadata Replace lastModeRef with unified gameMetadataRef Fix sendMatchData to send wrapped {data:{row,col}} payload Update TicTacToe state handling (winner logic, board.grid) Adjust UI to read symbols from player.metadata.symbol Update matching logic to find player index via player.user_id Improve safety checks for missing game/mode in matchmaking --- src/tictactoe/Board.tsx | 19 ++++++++++--- src/tictactoe/Player.tsx | 5 +++- src/tictactoe/TicTacToe.tsx | 20 ++++++++++---- src/tictactoe/providers/NakamaProvider.tsx | 31 ++++++++++++++++------ 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/tictactoe/Board.tsx b/src/tictactoe/Board.tsx index b6bb2e1..04b4b42 100644 --- a/src/tictactoe/Board.tsx +++ b/src/tictactoe/Board.tsx @@ -3,11 +3,18 @@ import { motion, AnimatePresence } from "framer-motion"; import { useNakama } from "./providers/NakamaProvider"; import getHaiku from "./utils/haikus"; +export interface PlayerModel { + user_id: string; + username: string; + index: number; + metadata: Record; // e.g. { symbol: "X" } +} + interface BoardProps { board: string[][]; turn: number; winner: string | null; - players: string[]; + players: PlayerModel[]; myUserId: string | null; onCellClick: (row: number, col: number) => void; } @@ -20,17 +27,21 @@ export default function Board({ myUserId, onCellClick, }: BoardProps) { - const myIndex = players.indexOf(myUserId ?? ""); + const myIndex = players.findIndex(p => p.user_id === myUserId); const gameReady = players.length === 2; const { matchId } = useNakama(); const mySymbol = - myIndex === 0 ? "X" : myIndex === 1 ? "O" : null; + myIndex !== null && players[myIndex] + ? players[myIndex].metadata?.symbol ?? null + : null; const opponentSymbol = - mySymbol === "X" ? "O" : mySymbol === "O" ? "X" : null; + myIndex !== null && players.length === 2 + ? players[1 - myIndex].metadata?.symbol ?? null + : null; const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex; diff --git a/src/tictactoe/Player.tsx b/src/tictactoe/Player.tsx index 2846a8c..e0922f4 100644 --- a/src/tictactoe/Player.tsx +++ b/src/tictactoe/Player.tsx @@ -41,7 +41,10 @@ export default function Player({ setIsQueueing(true); try { - const ticket = await joinMatchmaker(selectedMode); + const ticket = await joinMatchmaker({ + game: 'tictactoe', + mode: selectedMode, + }); console.log("Queued:", ticket); } catch (err) { console.error("Matchmaking failed:", err); diff --git a/src/tictactoe/TicTacToe.tsx b/src/tictactoe/TicTacToe.tsx index 8961441..19453f0 100644 --- a/src/tictactoe/TicTacToe.tsx +++ b/src/tictactoe/TicTacToe.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { useNakama } from "./providers/NakamaProvider"; import Board from "./Board"; import Player from "./Player"; +import { PlayerModel } from "./Board"; export default function TicTacToe() { const [board, setBoard] = useState([ @@ -12,7 +13,7 @@ export default function TicTacToe() { ]); const [turn, setTurn] = useState(0); const [winner, setWinner] = useState(null); - const [players, setPlayers] = useState([]); + const [players, setPlayers] = useState([]); const { sendMatchData, onMatchData, matchId, session } = useNakama(); @@ -26,9 +27,18 @@ export default function TicTacToe() { const state = msg.data; console.log("Match state:", state); - setBoard(state.board); + setBoard(state.board.grid); setTurn(state.turn); - setWinner(state.winner || null); + if (state.winner >= 0) { + // Somebody actually won (0 or 1) + setWinner(state.winner); + // } else if (state.game_over) { + // // Game ended but winner = -1 → draw + // setWinner("draw"); + } else { + // Ongoing game, no winner + setWinner(null); + } setPlayers(state.players || []); } } @@ -50,7 +60,7 @@ export default function TicTacToe() { function handleCellClick(row: number, col: number) { if (!matchId) return; - sendMatchData(matchId, 1, { row, col }); // OpMove=1 + sendMatchData(matchId, 1, {data: {row, col}}); } return ( diff --git a/src/tictactoe/providers/NakamaProvider.tsx b/src/tictactoe/providers/NakamaProvider.tsx index 2db44e2..2c44e90 100644 --- a/src/tictactoe/providers/NakamaProvider.tsx +++ b/src/tictactoe/providers/NakamaProvider.tsx @@ -25,6 +25,11 @@ function getOrCreateDeviceId(): string { return id; } +type GameMetadata = { + game: string; + mode: string; +}; + export interface NakamaContextType { client: Client; socket: Socket | null; @@ -33,7 +38,7 @@ export interface NakamaContextType { loginOrRegister(username: string): Promise; logout(): Promise; - joinMatchmaker(mode: string): Promise; + joinMatchmaker(gameMetadata: GameMetadata): Promise; joinMatch(matchId: string): Promise; sendMatchData(matchId: string, op: number, data: object): void; @@ -62,10 +67,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { ) ); + const gameMetadataRef = React.useRef(null); const [session, setSession] = useState(null); const [socket, setSocket] = useState(null); const [matchId, setMatchId] = useState(null); - const lastModeRef = React.useRef(null); const socketRef = React.useRef(null); async function autoLogin() { @@ -140,9 +145,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { if (!matched.match_id) { console.warn("[Nakama] Match rejected by server. Auto-requeueing..."); - if (lastModeRef.current) { + if (gameMetadataRef.current) { try { - await joinMatchmaker(lastModeRef.current); + await joinMatchmaker(gameMetadataRef.current); } catch (e) { console.error("[Nakama] Requeue failed:", e); } @@ -185,19 +190,28 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { // ---------------------------------------------------- // MATCHMAKING // ---------------------------------------------------- - async function joinMatchmaker(mode: string) { + async function joinMatchmaker(gameMetadata: GameMetadata) { const socket = socketRef.current; + const game = gameMetadata.game; + const mode = gameMetadata.mode; if (!socket) throw new Error("socket missing"); - console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`); + if (!game || game.trim() === "") { + throw new Error("Matchmaking requires a game name"); + } + + if (!mode || mode.trim() === "") { + throw new Error("Matchmaking requires a mode"); + } + console.log(`[Nakama] Matchmaking... game="${game}" mode="${mode}"`); const ticket: MatchmakerTicket = await socket.addMatchmaker( `*`, // query 2, // min count 2, // max count - { mode } // stringProperties + { game, mode } ); - lastModeRef.current = mode; + gameMetadataRef.current = { game, mode }; return ticket.ticket; } @@ -217,6 +231,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) { // ---------------------------------------------------- function sendMatchData(matchId: string, op: number, data: object) { if (!socket) return; + console.log("[Nakama] Sending match state:", matchId, op, data); socket.sendMatchState(matchId, op, JSON.stringify(data)); }