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 getHaiku from "./utils/haikus";
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // 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;

View File

@@ -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);

View File

@@ -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<string[][]>([
@@ -12,7 +13,7 @@ export default function TicTacToe() {
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
const [players, setPlayers] = useState<string[]>([]);
const [players, setPlayers] = useState<PlayerModel[]>([]);
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 (

View File

@@ -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<void>;
logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>;
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
joinMatch(matchId: string): Promise<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 [socket, setSocket] = useState<Socket | null>(null);
const [matchId, setMatchId] = useState<string | null>(null);
const lastModeRef = React.useRef<string | null>(null);
const socketRef = React.useRef<Socket | null>(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));
}