4 Commits

Author SHA1 Message Date
df0f502191 common files for board games
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-01 18:25:22 +05:30
16355d8028 winner 0 index causing it to draw. using username for winner string. break the highliting X or 0 for cell, which was broken anyway 2025-12-01 18:24:52 +05:30
7671e9b2cc feat: add draw-state support using game_over flag and update UI handling
Updated match data callback to interpret { game_over: true, winner: -1 } as a draw.

Added winner = "draw" UI state for display and disabling board interactions.

Updated status text in Board component to show “Draw!” when applicable.

Adjusted winner highlighting logic to avoid highlighting any symbol during draw.

Ensured ongoing games always set winner = null for consistent behavior.
2025-12-01 18:16:46 +05:30
fa02e8b4e4 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
2025-12-01 18:12:18 +05:30
5 changed files with 64 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "tictactoe-vite",
"version": "v1.0.0",
"version": "v1.1.0",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -3,11 +3,19 @@ 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[];
gameOver: boolean | null;
players: PlayerModel[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}
@@ -16,21 +24,26 @@ export default function Board({
board,
turn,
winner,
gameOver,
players,
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;
@@ -42,6 +55,8 @@ export default function Board({
status = "Waiting for opponent...";
} else if (winner) {
status = `Winner: ${winner}`;
} else if (gameOver) {
status = `Draw!!!`;
} else if (myIndex === -1) {
status = "Spectating";
} else {

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,8 @@ export default function TicTacToe() {
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
const [players, setPlayers] = useState<string[]>([]);
const [gameOver, setGameOver] = useState<boolean | null>(null);
const [players, setPlayers] = useState<PlayerModel[]>([]);
const { sendMatchData, onMatchData, matchId, session } = useNakama();
@@ -26,9 +28,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);
setGameOver(state.game_over);
if (state.winner >= 0) {
setWinner(state.players[state.winner].username);
// } 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 +61,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 (
@@ -108,6 +119,7 @@ export default function TicTacToe() {
board={board}
turn={turn}
winner={winner}
gameOver={gameOver}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}

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