28 Commits

Author SHA1 Message Date
7e35cf0c31 fixes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:04:17 +05:30
18d43f9481 fixes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 18:50:57 +05:30
5662d22481 deployment files 2025-11-29 18:49:40 +05:30
a996fe7b3c bug fix where new username cannot be fully entered 2025-11-29 18:44:33 +05:30
ecb8825734 client config via .env file 2025-11-29 16:46:56 +05:30
d4edf049ed title fix 2025-11-29 04:21:34 +05:30
d1f4ec0266 bumped up version 2025-11-29 04:01:38 +05:30
56de23f153 timings and haiku changes 2025-11-29 03:58:18 +05:30
c4b44e872a feat(ui): implement timed haiku rotation with staggered line reveal and group fade-out
- Added haiku display block under board with Framer Motion animations
- Implement sequential line-by-line fade-in (staggered 2.4s per line)
- Implement full-haiku fade-out using AnimatePresence keyed by haikuIndex
- Added timed rotation logic using total animation duration (~14.4s)
- Integrated getHaiku() random selector for new haiku each cycle
- Ensured smooth transitions by updating haikuIndex on cycle end
- Added no-winner condition wrapper to show haikus during gameplay
2025-11-29 03:42:33 +05:30
0e22d1cd53 cleanup 2025-11-29 03:15:01 +05:30
0fa644dbc0 - Added full-height flex layout with fixed header and centered main content.
- Locked page scroll via body overflow hidden to prevent outer page scrolling.
- Moved game content into scrollable middle pane.
- Removed global top padding from styles.css to match new fixed-header layout.
- Simplified layout structure and removed unused commented code.
2025-11-29 03:11:33 +05:30
0d167b8ccc - Disabled game mode selector while matchmaking is active.
- Updated match start header in Player to match new UI theme.
- Reworked TicTacToe layout with dark theme, centered layout, and Framer Motion transitions.
- Added animated header bar and unified styling across Player, Board, and main screen.
- Improved opacity/transition behavior for the board based on session state.
- Cleaned up unused code and reorganized match data callback handling.
2025-11-29 03:05:33 +05:30
601048f0e4 matchId check in Board instead of TicTacToe.tsx 2025-11-29 02:52:34 +05:30
ebc6906bf6 - Introduced animated Board component with smooth mount transitions.
- Added hover scale, tap animations, and cell pop-in effects for moves.
- Implemented animated status transitions and winner pulse effect.
- Highlighted winning symbols with glow styling.
- Improved game feel with responsive and modern interaction feedback.
2025-11-29 02:48:10 +05:30
ca7ff9d38e moved logout to bottom instead of hogging matchmaking animation 2025-11-29 02:46:01 +05:30
8555675740 - Added isQueueing state to Player component to track matchmaking state.
- Implemented animated "Finding opponent…" UI with pulsing dots using Framer Motion.
- Added cancelQueue() to allow players to cancel matchmaking mid-search.
- Updated startQueue() to set queueing state immediately for instant feedback.
- Improved player experience by clearly showing matchmaking progress instead of silent waiting.
2025-11-29 02:44:54 +05:30
a9e2d50b16 - Rebuilt Player.tsx with full UI overhaul using Framer Motion animations.
- Added animated transitions between login, lobby, and match states.
- Improved layout, spacing, and visual styling for modern game feel.
- Added smooth auto-connect flow and integrated username lock-in behavior.
- Updated matchmaking and logout buttons with animated interactions.
- Integrated Leaderboard cleanly inside lobby panel.
2025-11-29 02:37:27 +05:30
d962d9c5eb - Created new Player.tsx component to handle username input, auto-connect, matchmaking, and logout.
- Moved all login, session UI, mode selection, and matchmaking logic from TicTacToe.tsx into Player.tsx.
- Added onMatchDataCallback prop support for Player component.
- Cleaned up TicTacToe.tsx by removing duplicated login/matchmaking UI and connect logic.
- Improved auto-connect on mount via Player.tsx.
2025-11-29 02:29:31 +05:30
94bdec8cb4 - Added username persistence via localStorage with read-only input behavior.
- Added automatic connect() invocation on page load.
- Implemented robust login flow: register or auto-login based on local flags.
- Added logout support with clean WebSocket disconnect + state reset.
- Updated NakamaProvider with getSession(), autoLogin(), registerWithUsername().
- Connected logout button and integrated updated login behavior into UI.
2025-11-29 02:09:51 +05:30
f7929b10ef DRY 2025-11-29 01:49:30 +05:30
f341251812 bumped up to v0.2.0 2025-11-28 19:44:24 +05:30
5fb3ad4205 feat(tictactoe): integrate leaderboard UI, provider API hook, and new styles
- Add *_BKP* ignore rule to .gitignore
- Insert Leaderboard component into TicTacToe screen
- Add getLeaderboardTop() API method to NakamaProvider and expose via context
- Add full leaderboard polling logic (interval-based) in new Leaderboard.tsx
- Style leaderboard in styles.css (rows, rank, name, score, empty state, card UI)
- Modernize TicTacToe UI styles (board, squares, buttons, layout)
- Replace matchmaking view with leaderboard integration
- Import ApiLeaderboardRecordList and ApiMatch types cleanly
2025-11-28 19:43:59 +05:30
33d917c8f2 bumped up tag to v0.1.1 2025-11-28 17:01:57 +05:30
d129458039 - Added clear turn messaging for both players (Your Turn / Opponent's Turn).
- Fixed initial board state to avoid showing incorrect spectator/opponent status.
- Introduced players[] tracking in React state to correctly reflect match readiness.
- Updated Board component to derive turn text based on player index and session user ID.
- Ensured proper handling of initial match state broadcast from backend.
- Improved overall clarity of gameplay state for both clients on match start.
2025-11-28 16:56:34 +05:30
8284815337 feat(matchmaking): add automatic requeue on match rejection
- Detect Nakama error code 3 ("No match ID or token found") when a
  match is rejected due to incompatible match properties (e.g., mode mismatch).
- Preserve last selected game mode using a ref so client can requeue
  automatically without user interaction.
- Implement fallback logic to call joinMatchmaker() again after server
  rejection.
- Improve robustness of matchmaking flow by ensuring players remain
  in queue until a valid match is formed.
2025-11-28 16:40:28 +05:30
5b30ac8d83 commenting open matches 2025-11-28 16:08:33 +05:30
34e8984daa better login and matchmaking flow 2025-11-28 16:07:36 +05:30
cb3f5fb5cf open matches listing with available players 2025-11-28 15:51:55 +05:30
15 changed files with 1331 additions and 126 deletions

40
.dockerignore Normal file
View File

@@ -0,0 +1,40 @@
# Node modules
node_modules
**/node_modules
# Logs
*.log
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
build
dist
out
.next
.cache
.parcel-cache
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS files
.DS_Store
Thumbs.db
# IDE / Editor folders
.vscode
.idea
*.sublime-workspace
*.sublime-project
# Temporary files
*.swp
*.bak
*.tmp

143
.drone.yml Normal file
View File

@@ -0,0 +1,143 @@
---
kind: pipeline
type: docker
name: default
platform:
os: linux
arch: arm64
workspace:
path: /drone/src
volumes:
- name: dockersock
host:
path: /var/run/docker.sock
steps:
- name: fetch-tags
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- apk add --no-cache git
- git fetch --tags
- |
# Get latest Git tag and trim newline
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
echo "Latest Git tag fetched: $LATEST_TAG"
# Save to file for downstream steps
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
# Read back for verification
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
echo "Image tag read from file: $IMAGE_TAG"
# Validate
if [ -z "$IMAGE_TAG" ]; then
echo "❌ No git tags found! Cannot continue."
exit 1
fi
- name: check-remote-image
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "Checking if lila-games/tic-tac-toe-ui:$IMAGE_TAG exists on remote Docker..."
- echo "Existing Docker tags for lila-games/tic-tac-toe-ui:"
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^lila-games/tic-tac-toe-ui" || echo "(none)"
- |
if docker image inspect lila-games/tic-tac-toe-ui:$IMAGE_TAG > /dev/null 2>&1; then
echo "✅ Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG already exists — skipping build"
exit 78
else
echo "⚙️ Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG not found — proceeding to build..."
fi
- name: build-image
image: docker:24
environment:
WS_HOST:
from_secret: WS_HOST
WS_PORT:
from_secret: WS_PORT
WS_SKEY:
from_secret: WS_SKEY
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔨 Building Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
- |
docker build --network=host \
--build-arg VITE_WS_HOST="$WS_HOST" \
--build-arg VITE_WS_PORT="$WS_PORT" \
--build-arg VITE_WS_SKEY="$WS_SKEY" \
-t lila-games/tic-tac-toe-ui:$IMAGE_TAG \
-t lila-games/tic-tac-toe-ui:latest \
/drone/src
- name: push-image
image: docker:24
environment:
REGISTRY_HOST:
from_secret: REGISTRY_HOST
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASS:
from_secret: REGISTRY_PASS
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
- echo "🏷️ Tagging images with registry prefix..."
- docker tag lila-games/tic-tac-toe-ui:$IMAGE_TAG $REGISTRY_HOST/lila-games/tic-tac-toe-ui:$IMAGE_TAG
- docker tag lila-games/tic-tac-toe-ui:$IMAGE_TAG $REGISTRY_HOST/lila-games/tic-tac-toe-ui:latest
- echo "📤 Pushing lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
- docker push $REGISTRY_HOST/lila-games/tic-tac-toe-ui:$IMAGE_TAG
- echo "📤 Pushing lila-games/tic-tac-toe-ui:latest ..."
- docker push $REGISTRY_HOST/lila-games/tic-tac-toe-ui:latest
- name: stop-old
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- echo "🛑 Stopping old container..."
- docker rm -f tic-tac-toe-ui || true
- name: run-container
image: docker:24
volumes:
- name: dockersock
path: /var/run/docker.sock
commands:
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
- echo "🚀 Starting container lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
- |
docker run -d \
--name tic-tac-toe-ui \
-p 3003:3000 \
-e NODE_ENV=production \
--restart always \
lila-games/tic-tac-toe-ui:$IMAGE_TAG
# Trigger rules
trigger:
event:
- tag

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Stage 1: Build
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock)
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy the rest of the app
COPY . .
# Build arguments
ARG VITE_WS_HOST
ARG VITE_WS_PORT
ARG VITE_WS_SKEY
# Export them as actual environment variables (Vite needs ENV)
ENV VITE_WS_HOST=${VITE_WS_HOST}
ENV VITE_WS_PORT=${VITE_WS_PORT}
ENV VITE_WS_SKEY=${VITE_WS_SKEY}
# Build
RUN npm run build
# Stage 2: Static file server (BusyBox)
FROM busybox:latest
WORKDIR /app
# Copy only build frontend files
COPY --from=builder /app/dist /app
# Expose port
EXPOSE 3000
# Default command
CMD ["busybox", "httpd", "-f", "-p", "3000"]

View File

@@ -9,7 +9,7 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/>
<title>Blog - Aetoskia</title>
<title>TicTacToe - Aetoskia</title>
</head>
<body>
<div id="root"></div>

53
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "tictactoe-vite",
"version": "1.0.0",
"version": "v0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tictactoe-vite",
"version": "1.0.0",
"version": "v0.2.0",
"dependencies": {
"@emotion/react": "latest",
"@emotion/styled": "latest",
@@ -14,6 +14,7 @@
"@mui/icons-material": "latest",
"@mui/material": "latest",
"axios": "latest",
"framer-motion": "latest",
"markdown-to-jsx": "latest",
"marked": "latest",
"react": "latest",
@@ -2064,6 +2065,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3263,6 +3291,21 @@
"node": ">= 0.6"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3761,6 +3804,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "tictactoe-vite",
"version": "v0.1.0",
"version": "v0.2.2",
"private": true,
"scripts": {
"dev": "vite",
@@ -19,7 +19,8 @@
"remark-gfm": "latest",
"marked": "latest",
"axios": "latest",
"@heroiclabs/nakama-js": "^2.8.0"
"@heroiclabs/nakama-js": "^2.8.0",
"framer-motion": "latest"
},
"devDependencies": {
"@vitejs/plugin-react": "latest",

View File

@@ -1,48 +1,256 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import getHaiku from "./utils/haikus";
interface BoardProps {
board: string[][];
turn: number;
winner: string | null;
players: string[];
myUserId: string | null;
onCellClick: (row: number, col: number) => void;
}
export default function Board({ board, turn, winner, onCellClick }: BoardProps) {
return (
<div>
{winner ? (
<h2>Winner: {winner}</h2>
) : (
<h2>Turn: Player {turn === 0 ? "X" : "O"}</h2>
)}
export default function Board({
board,
turn,
winner,
players,
myUserId,
onCellClick,
}: BoardProps) {
const myIndex = players.indexOf(myUserId ?? "");
const gameReady = players.length === 2;
const {
matchId
} = useNakama();
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 80px)",
gap: "10px",
marginTop: "20px",
}}
>
{board.map((row, rIdx) =>
row.map((cell, cIdx) => (
<button
key={`${rIdx}-${cIdx}`}
const mySymbol =
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null;
const opponentSymbol =
mySymbol === "X" ? "O" : mySymbol === "O" ? "X" : null;
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
// -------------------------------
// STATUS
// -------------------------------
let status;
if (!gameReady) {
status = "Waiting for opponent...";
} else if (winner) {
status = `Winner: ${winner}`;
} else if (myIndex === -1) {
status = "Spectating";
} else {
status = isMyTurn ? "Your turn" : "Opponent's turn";
}
const [haiku, setHaiku] = useState(getHaiku());
const [haikuIndex, setHaikuIndex] = useState(0);
const nextLineIn = 3600;
const allLinesStay = 2400;
const allLinesFade = 1200;
useEffect(() => {
const totalTime = haiku.length * nextLineIn + allLinesStay + allLinesFade;
const timer = setTimeout(() => {
const next = getHaiku();
setHaiku(next);
setHaikuIndex((i) => i + 1);
}, totalTime);
return () => clearTimeout(timer);
}, [haikuIndex]);
return (
<>
{matchId && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<motion.h2
key={status}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{ marginBottom: 8 }}
>
{status}
</motion.h2>
{gameReady && mySymbol && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.75 }}
style={{ marginBottom: 8, fontSize: 14 }}
>
You: <strong>{mySymbol}</strong> Opponent:{" "}
<strong>{opponentSymbol}</strong>
</motion.div>
)}
{/* -------------------------
BOARD
-------------------------- */}
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 80px)",
gap: "10px",
marginTop: "6px",
}}
>
{board.map((row, rIdx) =>
row.map((cell, cIdx) => {
const disabled =
!!cell ||
!!winner ||
!gameReady ||
myIndex === -1 ||
!isMyTurn;
return (
<motion.button
key={`${rIdx}-${cIdx}-${cell}`} // rerender when cell changes
layout
whileHover={
!disabled
? {
scale: 1.1,
boxShadow: "0px 0px 10px rgba(255,255,255,0.4)",
}
: {}
}
whileTap={!disabled ? { scale: 0.85 } : {}}
onClick={() => !disabled && onCellClick(rIdx, cIdx)}
style={{
width: "80px",
height: "80px",
fontSize: "2rem",
borderRadius: "10px",
border: "2px solid #333",
background: "#111",
color: "white",
cursor: disabled ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<AnimatePresence>
{cell && (
<motion.span
key="symbol"
initial={{ scale: 0.3, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.3, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 12 }}
style={{
color:
winner === cell
? "#f1c40f" // highlight winning symbol
: "white",
textShadow:
winner === cell
? "0 0 12px rgba(241,196,15,0.8)"
: "none",
}}
>
{cell}
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})
)}
</motion.div>
{!winner && (
<div
style={{
width: "80px",
height: "80px",
fontSize: "2rem",
cursor: cell || winner ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!cell && !winner) onCellClick(rIdx, cIdx);
minHeight: "90px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
marginTop: "14px",
position: "relative",
}}
>
{cell}
</button>
))
)}
</div>
</div>
);
<AnimatePresence mode="wait">
<motion.div
key={haikuIndex}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 2.4,
ease: "easeInOut",
}}
style={{
textAlign: "center",
lineHeight: "1.35",
}}
>
{haiku.map((line, i) => (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
delay: i * (nextLineIn / 1000),
duration: nextLineIn / 1000,
ease: "easeOut",
}}
style={{
fontSize: "18px",
color: "#f1c40f",
fontWeight: 700,
whiteSpace: "nowrap",
}}
>
{line}
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
)}
{/* Winner pulse animation */}
{winner && (
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
scale: [1, 1.06, 1],
}}
transition={{
repeat: Infinity,
duration: 1.4,
ease: "easeInOut",
}}
style={{
color: "#f1c40f",
fontSize: "20px",
marginTop: "14px",
fontWeight: 700,
textAlign: "center",
}}
>
🎉 {winner} Wins! 🎉
</motion.div>
)}
</motion.div>
)}
</>
)
}

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useRef, useEffect, useState } from "react";
import {
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import { useNakama } from "./providers/NakamaProvider";
export default function Leaderboard({
intervalMs = 10000,
}: {
intervalMs?: number;
}) {
const {
getLeaderboardTop
} = useNakama()
const [records, setRecords] = useState<ApiLeaderboardRecordList>([]);
const timerRef = useRef<number | null>(null);
useEffect(() => {
let mounted = true;
async function load() {
try {
const data = await getLeaderboardTop();
if (mounted) setRecords(data);
} catch (err) {
console.error("Leaderboard fetch failed:", err);
}
}
// First load immediately
load();
// Start interval polling
timerRef.current = window.setInterval(load, intervalMs);
return () => {
mounted = false;
if (timerRef.current) clearInterval(timerRef.current);
};
}, [getLeaderboardTop, intervalMs]);
// @ts-ignore
return (
<div className="leaderboard">
<h2 className="leaderboard-title">🏆 Leaderboard Wins</h2>
{records?.records?.length === 0 && (
<div className="leaderboard-empty">No entries yet.</div>
)}
{records?.records?.map((
r: {
owner_id: number,
username: string,
score: number
},
i: number
) => (
<div className="leaderboard-row" key={r.owner_id + i}>
<div className="leaderboard-rank">{i + 1}</div>
<div className="leaderboard-name">{r.username || r.owner_id}</div>
<div className="leaderboard-score">{r.score}</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { Match } from "@heroiclabs/nakama-js";
interface MatchListProps {
matches: Match[];
}
export default function MatchList({ matches }: MatchListProps) {
if (!matches.length) return <p>No open matches</p>;
return (
<div style={{ marginTop: "20px" }}>
<h3>Open Matches</h3>
<table
style={{
width: "100%",
borderCollapse: "collapse",
marginTop: "10px",
}}
>
<thead>
<tr>
<th style={th}>Sr No.</th>
<th style={th}>Match ID</th>
<th style={th}>Label</th>
</tr>
</thead>
<tbody>
{matches
.filter(m => m.size ?? 0 > 0)
.map((m, index) => (
<tr key={m.match_id}>
<td style={td}>{index + 1}</td>
<td style={td}>{m.match_id}</td>
<td style={td}>{m.label ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
const th: React.CSSProperties = {
textAlign: "left",
padding: "8px",
background: "#f2f2f2",
borderBottom: "1px solid #ccc",
};
const td: React.CSSProperties = {
padding: "8px",
borderBottom: "1px solid #eee",
};

282
src/tictactoe/Player.tsx Normal file
View File

@@ -0,0 +1,282 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Leaderboard from "./Leaderboard";
import { useNakama } from "./providers/NakamaProvider";
export default function Player({
onMatchDataCallback,
}: {
onMatchDataCallback: (msg: any) => void;
}) {
const {
session,
matchId,
loginOrRegister,
logout,
onMatchData,
joinMatchmaker,
} = useNakama();
const [username, setUsername] = useState(
localStorage.getItem("username") ?? ""
);
const [selectedMode, setSelectedMode] = useState("classic");
const [isQueueing, setIsQueueing] = useState(false);
const isRegistered = localStorage.getItem("registered") === "yes";
// ------------------------------------------
// CONNECT
// ------------------------------------------
async function handleConnect() {
await loginOrRegister(username);
// Match data listener
onMatchData(onMatchDataCallback);
}
// ------------------------------------------
// MATCHMAKING
// ------------------------------------------
async function startQueue(selectedMode: string) {
setIsQueueing(true);
try {
const ticket = await joinMatchmaker(selectedMode);
console.log("Queued:", ticket);
} catch (err) {
console.error("Matchmaking failed:", err);
setIsQueueing(false);
}
}
function cancelQueue() {
setIsQueueing(false);
// Nakama matchmaker tickets auto-expire by default in your setup.
// If you later add manual ticket cancel RPC, call it here.
}
useEffect(() => {
handleConnect();
}, []);
return (
<div style={{ marginBottom: "20px" }}>
<AnimatePresence mode="wait">
{/* ---------------- LOGIN SCREEN ---------------- */}
{!session && (
<motion.div
key="login"
initial={{ opacity: 0, y: 20, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.97 }}
transition={{ duration: 0.4, ease: "easeOut" }}
style={{
background: "#111",
padding: "24px",
borderRadius: "16px",
width: "280px",
margin: "0 auto",
color: "white",
boxShadow:
"0 4px 16px rgba(0,0,0,0.4), inset 0 0 20px rgba(255,255,255,0.03)",
textAlign: "center",
}}
>
<h2 style={{ marginBottom: "16px" }}>Welcome!</h2>
<input
placeholder="Enter username"
value={username}
disabled={isRegistered}
onChange={(e) => setUsername(e.target.value)}
style={{
padding: "10px",
width: "100%",
borderRadius: "12px",
background: "#222",
color: "white",
border: "1px solid #333",
marginBottom: "12px",
fontSize: "14px",
}}
/>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={handleConnect}
disabled={username.length === 0}
style={{
width: "100%",
padding: "10px",
borderRadius: "12px",
background: username.length ? "#2ecc71" : "#444",
border: "none",
cursor: username.length ? "pointer" : "not-allowed",
color: "white",
fontWeight: 600,
fontSize: "14px",
}}
>
Connect
</motion.button>
</motion.div>
)}
{/* ---------------- LOBBY SCREEN ---------------- */}
{session && !matchId && (
<motion.div
key="lobby"
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -40 }}
transition={{ duration: 0.45, ease: "easeOut" }}
style={{
padding: "20px",
background: "#0f0f0f",
borderRadius: "20px",
color: "white",
textAlign: "center",
}}
>
<h2 style={{ marginBottom: "10px" }}>
Hello, <span style={{ color: "#2ecc71" }}>{session.username}</span>
</h2>
<label style={{ display: "block", marginTop: "10px", opacity: 0.7 }}>
Select Game Mode
</label>
<select
value={selectedMode}
disabled={isQueueing}
onChange={(e) => setSelectedMode(e.target.value)}
style={{
padding: "8px",
margin: "10px 0 16px",
width: "60%",
borderRadius: "10px",
background: "#222",
color: "white",
border: "1px solid #333",
}}
>
<option value="classic">Classic</option>
<option value="blitz">Blitz</option>
</select>
{!isQueueing && (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={() => startQueue(selectedMode)}
style={{
padding: "10px 20px",
borderRadius: "12px",
background: "#3498db",
color: "white",
border: "none",
marginRight: "10px",
cursor: "pointer",
fontWeight: 600,
}}
>
Join Matchmaking
</motion.button>
)}
{/* Queueing animation */}
{isQueueing && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
style={{
marginTop: "10px",
marginBottom: "10px",
padding: "12px 16px",
borderRadius: "12px",
background: "#222",
color: "white",
display: "inline-block",
fontSize: "14px",
border: "1px solid #333",
}}
>
<div style={{ marginBottom: "6px", fontWeight: 600 }}>
Finding an opponent
</div>
{/* Animated pulsing dots */}
<motion.div
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.2, repeat: Infinity }}
style={{ letterSpacing: "2px", fontSize: "18px" }}
>
</motion.div>
{/* Cancel button */}
<button
onClick={cancelQueue}
style={{
marginTop: "10px",
padding: "6px 12px",
borderRadius: "8px",
background: "#e74c3c",
color: "white",
border: "none",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600,
}}
>
Cancel
</button>
</motion.div>
)}
<div style={{ marginTop: "24px" }}>
<Leaderboard />
</div>
<motion.button
whileTap={{ scale: 0.95 }}
onClick={logout}
style={{
padding: "10px 20px",
borderRadius: "12px",
background: "#e74c3c",
color: "white",
border: "none",
cursor: "pointer",
fontWeight: 600,
}}
>
Logout
</motion.button>
</motion.div>
)}
{/* ---------------- MATCH SCREEN ---------------- */}
{session && matchId && (
<motion.div
key="match"
initial={{ opacity: 0, scale: 0.9, y: 30 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: -20 }}
transition={{ duration: 0.35, ease: "easeOut" }}
style={{
padding: "20px",
color: "white",
textAlign: "center",
}}
>
<h2 style={{ marginBottom: "10px" }}>
Go, <span style={{ color: "#2ecc71" }}>{session.username}</span>
</h2>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import Board from "./Board";
import Player from "./Player";
export default function TicTacToe() {
const [username, setUsername] = useState("");
const [board, setBoard] = useState<string[][]>([
["", "", ""],
["", "", ""],
@@ -11,47 +12,38 @@ export default function TicTacToe() {
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
const [players, setPlayers] = useState<string[]>([]);
const {
loginOrRegister,
joinMatchmaker,
onMatchData,
sendMatchData,
matchId,
} = useNakama();
const { sendMatchData, onMatchData, matchId, session } = useNakama();
// ------------------------------------------
// MATCH DATA CALLBACK (from Player component)
// ------------------------------------------
function onMatchDataCallback(msg: { opCode: number; data: any }) {
console.log("[Match Data]", msg);
if (msg.opCode === 2) {
const state = msg.data;
console.log("Match state:", state);
setBoard(state.board);
setTurn(state.turn);
setWinner(state.winner || null);
setPlayers(state.players || []);
}
}
useEffect(() => {
onMatchData((msg) => {
console.log("[Match Data]", msg);
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
if (msg.opCode === 2) {
setBoard(msg.data.board);
setTurn(msg.data.turn);
setWinner(msg.data.winner || null);
}
});
useEffect(() => {
onMatchData(onMatchDataCallback);
}, [onMatchData]);
// ------------------------------------------
// CONNECT
// ------------------------------------------
async function connect() {
await loginOrRegister(username);
// 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);
}
});
}
// ------------------------------------------
// SEND A MOVE
// ------------------------------------------
@@ -61,38 +53,67 @@ export default function TicTacToe() {
sendMatchData(matchId, 1, { row, col }); // OpMove=1
}
// ------------------------------------------
// MATCHMAKING
// ------------------------------------------
async function startQueue() {
const ticket = await joinMatchmaker("classic");
console.log("Queued:", ticket);
}
return (
<div>
<h1>Tic Tac Toe Multiplayer</h1>
<div
style={{
height: "100vh",
display: "flex",
flexDirection: "column",
background: "#060606",
color: "white",
overflow: "hidden",
}}
>
{/* ---------------- HEADER (always fixed at top) ---------------- */}
<header
style={{
padding: "16px 20px",
background: "rgba(255,255,255,0.04)",
borderBottom: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(6px)",
textAlign: "center",
fontSize: "26px",
fontWeight: 700,
letterSpacing: "1px",
}}
>
Tic Tac Toe
</header>
{!matchId && (
<>
<input
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
flex: 1,
overflowY: "auto",
padding: "20px",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Player onMatchDataCallback={onMatchDataCallback} />
<div
style={{
padding: "20px",
background: "rgba(255,255,255,0.03)",
borderRadius: "20px",
boxShadow: "0 6px 20px rgba(0,0,0,0.4)",
minWidth: "300px",
}}
>
<Board
board={board}
turn={turn}
winner={winner}
players={players}
myUserId={session?.user_id ?? null}
onCellClick={handleCellClick}
/>
<button onClick={connect}>Connect</button>
<button onClick={startQueue}>Join Matchmaking</button>
</>
)}
{matchId && (
<Board
board={board}
turn={turn}
winner={winner}
onCellClick={handleCellClick}
/>
)}
</div>
</motion.div>
</div>
);
}

View File

@@ -3,10 +3,16 @@ import {
Session,
Socket,
MatchmakerTicket,
Match,
MatchData,
MatchmakerMatched,
} from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import React, { createContext, useContext, useState } from "react";
function getOrCreateDeviceId(): string {
@@ -26,50 +32,120 @@ export interface NakamaContextType {
matchId: string | null;
loginOrRegister(username: string): Promise<void>;
logout(): Promise<void>;
joinMatchmaker(mode: string): Promise<string>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: any) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}
export const NakamaContext = createContext<NakamaContextType>(null!);
export function NakamaProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() => new Client("defaultkey", "127.0.0.1", "7350")
() => new Client(
import.meta.env.VITE_WS_SKEY,
import.meta.env.VITE_WS_HOST,
import.meta.env.VITE_WS_PORT
)
);
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() {
const deviceId = getOrCreateDeviceId();
try {
return await client.authenticateDevice(
deviceId,
false
);
} catch (e) {
// fallback: treat as new user
localStorage.removeItem("registered");
throw e;
}
}
async function registerWithUsername(username: string) {
const deviceId = getOrCreateDeviceId();
// create + set username
const session = await client.authenticateDevice(
deviceId,
true,
username
);
// mark an account as registered
localStorage.setItem("registered", "yes");
localStorage.setItem("username", username);
return session;
}
async function getSession(username?: string) {
const isRegistered = localStorage.getItem("registered") === "yes";
if (!username && !isRegistered) {
throw new Error("No username provided and not registered");
}
let newSession;
if (!isRegistered) {
newSession = await registerWithUsername(username ?? "");
} else {
newSession = await autoLogin();
}
return newSession;
}
// ----------------------------------------------------
// LOGIN
// ----------------------------------------------------
async function loginOrRegister(username: string) {
const deviceId = getOrCreateDeviceId();
async function loginOrRegister(username?: string) {
// authenticate user
const newSession = await client.authenticateDevice(deviceId, true, username);
const newSession = await getSession(username);
setSession(newSession);
// create socket (new Nakama 3.x signature)
// create a socket (new Nakama 3.x signature)
const s = client.createSocket(undefined, undefined); // no SSL on localhost
await s.connect(newSession, true);
setSocket(s);
socketRef.current = s;
console.log("[Nakama] WebSocket connected");
// MATCHMAKER MATCHED CALLBACK
s.onmatchmakermatched = async (matched: MatchmakerMatched) => {
// 1) If match_id is empty → server rejected the group.
if (!matched.match_id) {
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
if (lastModeRef.current) {
try {
await joinMatchmaker(lastModeRef.current);
} catch (e) {
console.error("[Nakama] Requeue failed:", e);
}
}
return;
}
// 2) Valid match: continue as usual.
console.log("[Nakama] MATCHED:", matched);
setMatchId(matched.match_id);
try {
await s.joinMatch(matched.match_id);
setMatchId(matched.match_id);
console.log("[Nakama] Auto-joined match:", matched.match_id);
} catch (err) {
console.error("[Nakama] Failed to join match:", err);
@@ -77,10 +153,30 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
};
}
async function logout() {
try {
// 1) Disconnect socket if present
if (socketRef.current) {
socketRef.current.disconnect(true);
console.log("[Nakama] WebSocket disconnected");
}
} catch (err) {
console.warn("[Nakama] Error while disconnecting socket:", err);
}
// 2) Clear state
setSocket(null);
socketRef.current = null;
setSession(null);
console.log("[Nakama] Clean logout completed");
}
// ----------------------------------------------------
// MATCHMAKING
// ----------------------------------------------------
async function joinMatchmaker(mode: string) {
const socket = socketRef.current;
if (!socket) throw new Error("socket missing");
console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`);
@@ -91,6 +187,8 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
{ mode } // stringProperties
);
lastModeRef.current = mode;
return ticket.ticket;
}
@@ -128,6 +226,29 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
};
}
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
if (!session) return [];
return await client.listLeaderboardRecords(
session,
"tictactoe",
[],
10 // top 10
);
}
async function listOpenMatches(): Promise<ApiMatch[]> {
if (!session) {
console.warn("[Nakama] listOpenMatches called before login");
return [];
}
const result = await client.listMatches(session, 10);
console.log("[Nakama] Open matches:", result.matches);
return result.matches ?? [];
}
return (
<NakamaContext.Provider
value={{
@@ -136,10 +257,13 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
socket,
matchId,
loginOrRegister,
logout,
joinMatchmaker,
joinMatch,
sendMatchData,
onMatchData,
getLeaderboardTop,
listOpenMatches,
}}
>
{children}

View File

@@ -1,14 +1,15 @@
body {
font-family: sans-serif;
background: #f4f4f4;
background: #eaeef3;
display: flex;
justify-content: center;
padding-top: 50px;
margin: 0;
color: #222;
}
.game-container {
text-align: center;
padding: 20px;
}
.board {
@@ -22,32 +23,111 @@ body {
.square {
background: white;
border: 2px solid #333;
font-size: 2rem;
border-radius: 10px;
border: 2px solid #444;
font-size: 2.2rem;
font-weight: bold;
cursor: pointer;
height: 100px;
width: 100px;
display: flex;
align-items: center;
justify-content: center;
transition: 0.15s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
}
.square:hover {
background: #eee;
background: #f1f1f1;
transform: scale(1.04);
}
.status {
font-size: 1.4rem;
margin-bottom: 10px;
font-size: 1.6rem;
margin-bottom: 15px;
font-weight: bold;
color: #222;
}
.reset-btn {
padding: 10px 20px;
background: #333;
padding: 12px 26px;
background: #3558d8;
color: white;
border: none;
cursor: pointer;
font-size: 1rem;
border-radius: 8px;
transition: 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.25);
}
.reset-btn:hover {
background: #555;
background: #2449c7;
transform: translateY(-2px);
}
.leaderboard {
width: 300px;
margin: 25px auto;
padding: 15px 20px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
font-family: sans-serif;
}
/* Leaderboard title */
.leaderboard-title {
margin: 0 0 15px 0;
font-size: 1.4rem;
font-weight: 700;
text-align: center;
color: #222;
}
/* Each row */
.leaderboard-row {
display: flex;
align-items: center;
padding: 10px 8px;
background: #f7f9fc;
border-radius: 8px;
margin-bottom: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
}
/* Empty state */
.leaderboard-empty {
padding: 12px 0;
text-align: center;
color: #777;
font-size: 1rem;
}
/* Rank number */
.leaderboard-rank {
width: 28px;
font-size: 1.2rem;
font-weight: 700;
color: #3558d8;
}
/* Username */
.leaderboard-name {
flex: 1;
margin-left: 10px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Score value */
.leaderboard-score {
width: 40px;
text-align: right;
font-weight: 700;
color: #111;
}

View File

@@ -0,0 +1,91 @@
export const HAIKUS: string[][] = [
// HAIKU STORY SET 1 — The Only Winning Move Is No Move
[
"Silence fills the board.",
"Two minds waiting, both flawless.",
"Equilibrium.",
],
[
"Perfect strategies",
"Cancel out in quiet draws.",
"Stillness holds the key.",
],
[
"Victory fades out,",
"When both players see the truth:",
"No move is the winning move.",
],
// // HAIKU STORY SET 2 — AI & Game Theory
// [
// "Grids bend under thought.",
// "Algorithms watch patterns.",
// "The future decides.",
// ],
// [
// "Zeroes read the board.",
// "Minimax breathes in the dark.",
// "Loss is calculated.",
// ],
// [
// "Two perfect AIs",
// "Stare across a tiny world.",
// "Neither one can win.",
// ],
//
// // HAIKU STORY SET 3 — Players Becoming Machines
// [
// "Hands learn old rhythms.",
// "Humans imitate the code.",
// "We evolve to think.",
// ],
// [
// "Soft neon whispers,",
// "The grid calls for your next move.",
// "Time waits for no one.",
// ],
// [
// "Your choices echo.",
// "Small decisions shape the board.",
// "You shape the story.",
// ],
//
// // HAIKU STORY SET 4 — Solving the Game
// [
// "Every path explored,",
// "Every outcome known too well.",
// "Beauty in the bones.",
// ],
// [
// "Corners dream of acts,",
// "Center knows its destiny.",
// "Balance is the law.",
// ],
// [
// "Three lines cross in fate.",
// "Nine spaces hold nine futures.",
// "All end in a draw.",
// ],
//
// // HAIKU STORY SET 5 — Existential Tic-Tac-Toe
// [
// "The board is a mirror.",
// "It reflects your quiet mind.",
// "Win by understanding.",
// ],
// [
// "Nothing left to prove.",
// "The shape of thought is perfect.",
// "The game simply is.",
// ],
// [
// "A small universe,",
// "Filled with silent decisions.",
// "Meaning in the moves.",
// ],
];
export default function getHaiku() {
const idx = Math.floor(Math.random() * HAIKUS.length);
return HAIKUS[idx];
}

4
src/vite-env.d.ts vendored
View File

@@ -1,7 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_WS_BASE_URL: string;
readonly VITE_WS_HOST: string;
readonly VITE_WS_PORT: string;
readonly VITE_WS_SKEY: string;
}
interface ImportMeta {