diff --git a/package-lock.json b/package-lock.json
index d52fa5f..4ef3a42 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,15 +1,16 @@
{
- "name": "aetoskia-blog-app",
- "version": "0.2.5",
+ "name": "tictactoe-vite",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "aetoskia-blog-app",
- "version": "0.2.5",
+ "name": "tictactoe-vite",
+ "version": "1.0.0",
"dependencies": {
"@emotion/react": "latest",
"@emotion/styled": "latest",
+ "@heroiclabs/nakama-js": "^2.8.0",
"@mui/icons-material": "latest",
"@mui/material": "latest",
"axios": "latest",
@@ -841,6 +842,17 @@
"node": ">=18"
}
},
+ "node_modules/@heroiclabs/nakama-js": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/@heroiclabs/nakama-js/-/nakama-js-2.8.0.tgz",
+ "integrity": "sha512-E3bH/pqosASGHmVttsa708UjoLYkzZ4Sy3JUZV0TMK3oZK19QVyKrWhqjwyFwvKI2WyVf30xiRD+2ffvmfpw4A==",
+ "dependencies": {
+ "@scarf/scarf": "^1.1.1",
+ "base64-arraybuffer": "^1.0.2",
+ "js-base64": "^3.7.4",
+ "whatwg-fetch": "^3.6.2"
+ }
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1408,6 +1420,12 @@
"win32"
]
},
+ "node_modules/@scarf/scarf": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
+ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
+ "hasInstallScript": true
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1586,6 +1604,14 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/baseline-browser-mapping": {
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
@@ -2301,6 +2327,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/js-base64": {
+ "version": "3.7.8",
+ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
+ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3941,6 +3972,11 @@
}
}
},
+ "node_modules/whatwg-fetch": {
+ "version": "3.6.20",
+ "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
+ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index d843afd..a8cf21f 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,8 @@
"markdown-to-jsx": "latest",
"remark-gfm": "latest",
"marked": "latest",
- "axios": "latest"
+ "axios": "latest",
+ "@heroiclabs/nakama-js": "^2.8.0"
},
"devDependencies": {
"@vitejs/plugin-react": "latest",
diff --git a/src/main.jsx b/src/main.jsx
index f613e75..0279091 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,14 +1,14 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import TicTacToe from './tictactoe/TicTacToe';
-// import { TicTacToeProvider } from './tictactoe/providers/TicTacToeProvider';
+import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
import "./tictactoe/styles.css";
const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
- //
+
- // ,
+ ,
);
diff --git a/src/tictactoe/Board.tsx b/src/tictactoe/Board.tsx
index 7634a38..92520b0 100644
--- a/src/tictactoe/Board.tsx
+++ b/src/tictactoe/Board.tsx
@@ -1,11 +1,48 @@
-import Square from "./Square";
+import React from "react";
-export default function Board({ squares, onClick }) {
+interface BoardProps {
+ board: string[][];
+ turn: number;
+ winner: string | null;
+ onCellClick: (row: number, col: number) => void;
+}
+
+export default function Board({ board, turn, winner, onCellClick }: BoardProps) {
return (
-
- {squares.map((value, i) => (
-
onClick(i)} />
- ))}
+
+ {winner ? (
+
Winner: {winner}
+ ) : (
+
Turn: Player {turn === 0 ? "X" : "O"}
+ )}
+
+
+ {board.map((row, rIdx) =>
+ row.map((cell, cIdx) => (
+
+ ))
+ )}
+
);
}
diff --git a/src/tictactoe/TicTacToe.tsx b/src/tictactoe/TicTacToe.tsx
index 854741f..3396a7f 100644
--- a/src/tictactoe/TicTacToe.tsx
+++ b/src/tictactoe/TicTacToe.tsx
@@ -1,52 +1,99 @@
-import { useState } from "react";
+import { useState, useEffect } from "react";
+import { useNakama } from "./providers/NakamaProvider";
import Board from "./Board";
export default function TicTacToe() {
- const [board, setBoard] = useState(Array(9).fill(null));
- const [xIsNext, setXIsNext] = useState(true);
+ const [username, setUsername] = useState("");
+ const [matchId, setMatchId] = useState("");
- const winner = calculateWinner(board);
+ const [board, setBoard] = useState([
+ ["", "", ""],
+ ["", "", ""],
+ ["", "", ""]
+ ]);
+ const [turn, setTurn] = useState(0);
+ const [winner, setWinner] = useState(null);
- function handleSquareClick(i: number) {
- if (board[i] || winner) return;
+ const {
+ loginOrRegister,
+ joinMatchmaker,
+ joinMatch,
+ onMatchData,
+ socket,
+ sendMatchData
+ } = useNakama();
- const newBoard = board.slice();
- newBoard[i] = xIsNext ? "X" : "O";
+ // ------------------------------------------
+ // CONNECT
+ // ------------------------------------------
+ async function connect() {
+ await loginOrRegister(username);
- setBoard(newBoard);
- setXIsNext(!xIsNext);
+ // Match data listener
+ onMatchData((msg) => {
+ console.log("[Match Data]", msg);
+
+ if (msg.opCode === 2) {
+ const state = msg.data;
+
+ setBoard(state.board);
+ setTurn(state.turn);
+ setWinner(state.winner || null);
+ }
+ });
+
+ // When matchmaker finds a match
+ socket!.onmatchmakermatched = async (matched) => {
+ console.log("Matched:", matched);
+
+ const fullMatchId = matched.match_id;
+ setMatchId(fullMatchId);
+
+ await joinMatch(fullMatchId);
+ };
}
- function reset() {
- setBoard(Array(9).fill(null));
- setXIsNext(true);
+ // ------------------------------------------
+ // SEND A MOVE
+ // ------------------------------------------
+ function handleCellClick(row: number, col: number) {
+ if (!matchId) return;
+
+ sendMatchData(matchId, 1, { row, col }); // OpMove=1
}
- const status = winner
- ? `Winner: ${winner}`
- : `Next player: ${xIsNext ? "X" : "O"}`;
+ // ------------------------------------------
+ // MATCHMAKING
+ // ------------------------------------------
+ async function startQueue() {
+ const ticket = await joinMatchmaker("classic");
+ console.log("Queued:", ticket);
+ }
return (
-
-
Tic Tac Toe
-
{status}
-
-
+
+
Tic Tac Toe Multiplayer
+
+ {!matchId && (
+ <>
+ setUsername(e.target.value)}
+ />
+
+
+ >
+ )}
+
+ {matchId && (
+
+ )}
);
}
-
-// --- winner logic ---
-function calculateWinner(sq: any[]) {
- const lines = [
- [0,1,2],[3,4,5],[6,7,8],
- [0,3,6],[1,4,7],[2,5,8],
- [0,4,8],[2,4,6]
- ];
- for (let [a,b,c] of lines) {
- if (sq[a] && sq[a] === sq[b] && sq[a] === sq[c]) {
- return sq[a];
- }
- }
- return null;
-}
diff --git a/src/tictactoe/providers/NakamaProvider.tsx b/src/tictactoe/providers/NakamaProvider.tsx
new file mode 100644
index 0000000..2fac9f2
--- /dev/null
+++ b/src/tictactoe/providers/NakamaProvider.tsx
@@ -0,0 +1,160 @@
+import {
+ Client,
+ DefaultSocket,
+ Session,
+ Socket,
+ MatchmakerTicket,
+ Match,
+ MatchData,
+} from "@heroiclabs/nakama-js";
+import React, { createContext, useContext, useState } from "react";
+
+export interface NakamaContextType {
+ client: Client;
+ socket: Socket | null;
+ session: Session | null;
+
+ loginOrRegister: (username: string) => Promise
;
+ joinMatchmaker: (mode: string) => Promise;
+ leaveMatchmaker: (ticket: string) => Promise;
+ joinMatch: (matchId: string) => Promise;
+ sendMatchData: (matchId: string, opCode: number, data: object) => void;
+ onMatchData: (
+ cb: (msg: { opCode: number; data: any; userId: string | null }) => void
+ ) => void;
+}
+
+export const NakamaContext = createContext(null);
+
+export function NakamaProvider({ children }: { children: React.ReactNode }) {
+ const [client] = useState(
+ () => new Client(
+ "defaultkey",
+ "127.0.0.1",
+ "7350"
+ )
+ );
+
+ const [socket, setSocket] = useState(null);
+ const [session, setSession] = useState(null);
+
+ // -----------------------
+ // LOGIN / REGISTER
+ // -----------------------
+ async function loginOrRegister(username: string): Promise {
+ try {
+ const s = await client.authenticateDevice(username, true);
+ setSession(s);
+
+ const newSocket = new DefaultSocket(
+ client.host,
+ client.port,
+ false, // useSSL
+ false // verbose
+ );
+
+ await newSocket.connect(s, true);
+ setSocket(newSocket);
+
+ console.log("[Nakama] Connected WS");
+ return s;
+ } catch (err) {
+ console.error("[Nakama] Login error", err);
+ throw err;
+ }
+ }
+
+ // -----------------------
+ // MATCHMAKING
+ // -----------------------
+ async function joinMatchmaker(mode: string): Promise {
+ if (!socket) throw new Error("Socket not ready.");
+
+ const ticket = await socket.addMatchmaker(
+ `+mode:"${mode}"`, // query
+ 2, // min players
+ 2, // max players
+ { mode } // string properties
+ );
+
+ console.log("[Nakama] Matchmaker ticket:", ticket.ticket);
+ return ticket.ticket;
+ }
+
+ async function leaveMatchmaker(ticket: string): Promise {
+ if (socket) {
+ await socket.removeMatchmaker(ticket);
+ }
+ }
+
+ // -----------------------
+ // JOIN MATCH
+ // -----------------------
+ async function joinMatch(matchId: string): Promise {
+ if (!socket) throw new Error("Socket not connected.");
+ const match = await socket.joinMatch(matchId);
+ console.log("[Nakama] Joined match:", matchId);
+ return match;
+ }
+
+ // -----------------------
+ // SEND MATCH DATA
+ // -----------------------
+ function sendMatchData(matchId: string, opCode: number, data: object = {}): void {
+ if (!socket) return;
+ socket.sendMatchState(matchId, opCode, JSON.stringify(data));
+ }
+
+ // -----------------------
+ // LISTENERS
+ // -----------------------
+ function onMatchData(
+ cb: (msg: { opCode: number; data: any; userId: string | null }) => void
+ ) {
+ if (!socket) return;
+
+ socket.onmatchdata = (msg: MatchData) => {
+ const raw = msg.data;
+ let decoded: any = null;
+
+ try {
+ decoded = JSON.parse(new TextDecoder().decode(raw));
+ } catch {
+ decoded = raw; // fallback to raw binary
+ }
+
+ cb({
+ opCode: msg.op_code,
+ data: decoded,
+ userId: msg.presence?.user_id ?? null,
+ });
+ };
+ }
+
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useNakama(): NakamaContextType {
+ const ctx = useContext(NakamaContext);
+ if (!ctx) {
+ throw new Error("useNakama must be used inside a NakamaProvider");
+ }
+ return ctx;
+}