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
This commit is contained in:
2025-12-01 18:12:18 +05:30
parent fc29111fe1
commit fa02e8b4e4
4 changed files with 57 additions and 18 deletions

View File

@@ -3,11 +3,18 @@ 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";
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
interface BoardProps { interface BoardProps {
board: string[][]; board: string[][];
turn: number; turn: number;
winner: string | null; winner: string | null;
players: string[]; players: PlayerModel[];
myUserId: string | null; myUserId: string | null;
onCellClick: (row: number, col: number) => void; onCellClick: (row: number, col: number) => void;
} }
@@ -20,17 +27,21 @@ export default function Board({
myUserId, myUserId,
onCellClick, onCellClick,
}: BoardProps) { }: BoardProps) {
const myIndex = players.indexOf(myUserId ?? ""); const myIndex = players.findIndex(p => p.user_id === myUserId);
const gameReady = players.length === 2; const gameReady = players.length === 2;
const { const {
matchId matchId
} = useNakama(); } = useNakama();
const mySymbol = const mySymbol =
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null; myIndex !== null && players[myIndex]
? players[myIndex].metadata?.symbol ?? null
: null;
const opponentSymbol = 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; const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;

View File

@@ -41,7 +41,10 @@ export default function Player({
setIsQueueing(true); setIsQueueing(true);
try { try {
const ticket = await joinMatchmaker(selectedMode); const ticket = await joinMatchmaker({
game: 'tictactoe',
mode: selectedMode,
});
console.log("Queued:", ticket); console.log("Queued:", ticket);
} catch (err) { } catch (err) {
console.error("Matchmaking failed:", err); console.error("Matchmaking failed:", err);

View File

@@ -1,8 +1,9 @@
import { 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 Board from "./Board"; import Board from "./Board";
import Player from "./Player"; import Player from "./Player";
import { PlayerModel } from "./Board";
export default function TicTacToe() { export default function TicTacToe() {
const [board, setBoard] = useState<string[][]>([ const [board, setBoard] = useState<string[][]>([
@@ -12,7 +13,7 @@ export default function TicTacToe() {
]); ]);
const [turn, setTurn] = useState<number>(0); const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null); const [winner, setWinner] = useState<string | null>(null);
const [players, setPlayers] = useState<string[]>([]); const [players, setPlayers] = useState<PlayerModel[]>([]);
const { sendMatchData, onMatchData, matchId, session } = useNakama(); const { sendMatchData, onMatchData, matchId, session } = useNakama();
@@ -26,9 +27,18 @@ export default function TicTacToe() {
const state = msg.data; const state = msg.data;
console.log("Match state:", state); console.log("Match state:", state);
setBoard(state.board); setBoard(state.board.grid);
setTurn(state.turn); 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 || []); setPlayers(state.players || []);
} }
} }
@@ -50,7 +60,7 @@ export default function TicTacToe() {
function handleCellClick(row: number, col: number) { function handleCellClick(row: number, col: number) {
if (!matchId) return; if (!matchId) return;
sendMatchData(matchId, 1, { row, col }); // OpMove=1 sendMatchData(matchId, 1, {data: {row, col}});
} }
return ( return (

View File

@@ -25,6 +25,11 @@ function getOrCreateDeviceId(): string {
return id; return id;
} }
type GameMetadata = {
game: string;
mode: string;
};
export interface NakamaContextType { export interface NakamaContextType {
client: Client; client: Client;
socket: Socket | null; socket: Socket | null;
@@ -33,7 +38,7 @@ export interface NakamaContextType {
loginOrRegister(username: string): Promise<void>; loginOrRegister(username: string): Promise<void>;
logout(): Promise<void>; logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>; joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
joinMatch(matchId: string): Promise<void>; joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void; sendMatchData(matchId: string, op: number, data: object): void;
@@ -62,10 +67,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
) )
); );
const gameMetadataRef = React.useRef<GameMetadata | null>(null);
const [session, setSession] = useState<Session | null>(null); const [session, setSession] = useState<Session | null>(null);
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
const [matchId, setMatchId] = useState<string | null>(null); const [matchId, setMatchId] = useState<string | null>(null);
const lastModeRef = React.useRef<string | null>(null);
const socketRef = React.useRef<Socket | null>(null); const socketRef = React.useRef<Socket | null>(null);
async function autoLogin() { async function autoLogin() {
@@ -140,9 +145,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 (lastModeRef.current) { if (gameMetadataRef.current) {
try { try {
await joinMatchmaker(lastModeRef.current); await joinMatchmaker(gameMetadataRef.current);
} catch (e) { } catch (e) {
console.error("[Nakama] Requeue failed:", e); console.error("[Nakama] Requeue failed:", e);
} }
@@ -185,19 +190,28 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// ---------------------------------------------------- // ----------------------------------------------------
// MATCHMAKING // MATCHMAKING
// ---------------------------------------------------- // ----------------------------------------------------
async function joinMatchmaker(mode: string) { async function joinMatchmaker(gameMetadata: GameMetadata) {
const socket = socketRef.current; const socket = socketRef.current;
const game = gameMetadata.game;
const mode = gameMetadata.mode;
if (!socket) throw new Error("socket missing"); 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( const ticket: MatchmakerTicket = await socket.addMatchmaker(
`*`, // query `*`, // query
2, // min count 2, // min count
2, // max count 2, // max count
{ mode } // stringProperties { game, mode }
); );
lastModeRef.current = mode; gameMetadataRef.current = { game, mode };
return ticket.ticket; return ticket.ticket;
} }
@@ -217,6 +231,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
// ---------------------------------------------------- // ----------------------------------------------------
function sendMatchData(matchId: string, op: number, data: object) { function sendMatchData(matchId: string, op: number, data: object) {
if (!socket) return; if (!socket) return;
console.log("[Nakama] Sending match state:", matchId, op, data);
socket.sendMatchState(matchId, op, JSON.stringify(data)); socket.sendMatchState(matchId, op, JSON.stringify(data));
} }