61 Commits

Author SHA1 Message Date
b00519347a cleanup 2025-12-04 19:57:06 +05:30
8436cdbcdd refactor(game): unify move handling using typed payloads and remove UI-driven handlers
- Removed onCellClick from TicTacToeGameProps and migrated move sending inside TicTacToeGame
- Updated TicTacToeGame to:
  - import TicTacToePayload
  - use movePayload() builder
  - send moves using handleMove() with matchId + sendMatchData
  - remove old matchId destructuring duplication

- Updated BattleshipGame to:
  - import BattleshipPayload
  - use placePayload() and shootPayload() helpers
  - collapse place and shoot handlers into a single handleMove()
  - send typed payloads instead of raw objects

- Updated App.tsx:
  - Removed handleCellClick and no longer pass onCellClick down
  - Created typed ticTacToeProps and battleshipProps without UI callbacks
  - Cleaned unused state and simplified board rendering
  - Use {...commonProps} to propagate shared game state

- Updated props:
  - Removed TicTacToeGameProps.onCellClick
  - BattleshipGameProps continues to extend GameProps

- Removed duplicate MatchDataModel definition from interfaces/models
- Fixed imports to use revised models and payload types

This refactor completes the transition from UI-triggered handlers to
typed action payloads per game, significantly improving type safety,
consistency, and separation of concerns.
2025-12-04 19:56:46 +05:30
135fdd332d refactor(types): rename interfaces with *Model suffix and update references across codebase
- Renamed GameMetadata → GameMetadataModel for naming consistency
- Renamed Board → BoardModel
- Renamed MatchDataMessage → MatchDataModel (duplicate name removed)
- Updated all imports and references in:
  - NakamaProvider
  - contexts.ts
  - refs.ts
  - states.ts
  - Player.tsx
  - props and models files
- Updated GameState to use BoardModel instead of Board
- Updated NakamaContextType to use GameMetadataModel and MatchDataModel
- Updated NakamaRefs to store gameMetadataRef: RefObject<GameMetadataModel>
- Updated joinMatchmaker() and exitMatchmaker() signatures
- Updated onMatchData() to emit MatchDataModel
- Updated Player component to use PlayerProps type instead of inline typing

This commit standardizes naming conventions by ensuring all schema/interface
definitions follow the *Model naming pattern, improving clarity and type consistency
across the project.
2025-12-04 19:29:35 +05:30
8dc41fca2c using correct props instead of internal props for TicTacToeGame.tsx and BattleshipGame.tsx 2025-12-04 19:24:14 +05:30
fc7cb8efb6 renamed BattleShipGame.tsx to BattleshipGame.tsx to match props name. using props interface for both instead of using commonProps 2025-12-04 19:22:07 +05:30
06bdc92190 refactor(game): unify GameState, standardize board props, and rename game components
- Replaced multiple App-level state fields with unified GameState
- Added INITIAL_GAME_STATE and migrated App.tsx to use single game state
- Introduced GameProps as shared base props for all turn-based board games
- Created TicTacToeGameProps and BattleshipGameProps extending GameProps
- Updated TicTacToe and Battleship components to use new props
- Replaced verbose prop passing with spread {...commonProps}
- Updated renderGameBoard to use game.metadata consistently
- Renamed TicTacToeBoard -> TicTacToeGame for clarity
- Renamed BattleShipBoard -> BattleShipGame for naming consistency
- Updated all import paths to reflect new component names
- Replaced MatchDataMessage with MatchDataModel
- Moved GameState definition from models.ts to interfaces/states.ts
- Removed old board-specific prop structures and per-field state management
- Increased type safety and reduced duplication across the codebase

This commit consolidates game state flow, introduces a clean component props
architecture, and standardizes naming convention
2025-12-04 19:16:20 +05:30
650d7b7ed6 Revert "refactored PlayerModel to Player"
This reverts commit 68c2e3a8d9.
2025-12-04 18:59:06 +05:30
68c2e3a8d9 refactored PlayerModel to Player 2025-12-04 18:58:37 +05:30
ab9dd42689 refactor: separate Nakama provider concerns into context, refs, and state modules
- Extracted context contract to `contexts.ts` (NakamaContextType)
- Added strongly typed internal provider refs in `refs.ts`
  - socketRef: React.RefObject<Socket | null>
  - gameMetadataRef: React.RefObject<GameMetadata | null>
- Added `NakamaProviderState` in `states.ts` for React-managed provider state
  - session, socket, matchId, matchmakerTicket
- Refactored NakamaProvider to use new modular structure
  - Replaced scattered useState/useRef with structured internal state + refs
  - Updated onMatchData to use MatchDataMessage model
  - Replaced deprecated MutableRefObject typing with RefObject
  - Cleaned update patterns using `updateState` helper
- Updated imports to use new models and context structure
- Improved separation of responsibilities:
  - models = pure domain types
  - context = exposed provider API
  - refs = internal mutable runtime refs
  - state = provider-managed reactive state
- Ensured all Nakama provider functions fully typed and consistent with TS

This refactor improves clarity, type safety, and maintainability for the
Nakama real-time multiplayer provider.
2025-12-04 18:56:48 +05:30
51b051b34c hiding game mode for now as there's no different game modes for either tictactoe or battleship 2025-12-03 22:05:22 +05:30
eb6749dc0b feat(ui): add dynamic game board selection and hide board until match join
Added renderGameBoard() resolver for dynamic board rendering

Board now hidden before match join

Game auto-selected based on metadata.game from Player matchmaking

Updated header to use dynamic game name

Removed hardcoded Battleship board
2025-12-03 22:01:44 +05:30
ee31b010ac fixes 2025-12-03 21:40:18 +05:30
81a54aa93e feat(ui/battleship): integrate BattleshipBoard and metadata-driven placement/battle flow
- Added metadata state to App and wired incoming match metadata.
- Added Fleet + FLEET_ORDER in BattleShipBoard to drive ship placement order.
- Added nextShip + nextShipSize calculation for guided placement.
- Updated handlePlace and handleShoot to send structured payloads (action + data).
- Added lobby/placement/battle status messages.
- Updated grids to use shipBoard + shipName/shipSize props instead of generic grid.
- Fixed metadata access (state.Metadata vs state.metadata).
- Consolidated PlacementGrid usage and disabled it during battle phase.
- Added logging for debugging incoming battleship boards.
2025-12-03 21:01:41 +05:30
fe1cacb5ed feat(battleship): add complete Battleship game UI with placement & battle phases
- Implement BattleshipBoard with phase-based rendering (placement/battle)
- Add PlacementGrid for ship placement interaction
- Add ShotGrid for firing UI with turn validation
- Integrate match metadata (pX_placed, pX_ready, phase)
- Connect UI to Nakama sendMatchData (place + shoot actions)
- Add real-time board rendering for ships and shots
- Add status line, turn handling, and winner display
- Ensure compatibility with new backend ApplyMove/ApplyPlacement logic
2025-12-03 19:27:47 +05:30
2b0af9fd1f feat(tictactoe): migrate to multi-board state structure
- Replaced single `board` state with `boards` map in App.tsx
- Updated state parsing to use `state.boards` instead of `state.board.grid`
- Updated TicTacToeBoard props to accept `boards` instead of `board`
- Safely extracted TicTacToe board using `boards['tictactoe']?.grid ?? null`
- Added loading fallback when board is not yet available
- Updated rendering guards to prevent undefined map errors

This change fully aligns the frontend with the new multi-board MatchState
introduced on the server (supports TicTacToe, Battleship, and future games).
2025-12-03 17:36:29 +05:30
7b677653a7 cleanup 2025-12-01 20:58:21 +05:30
5c75541c25 de queue on cancle queue rather than before starting new queue 2025-12-01 20:58:08 +05:30
83ae342499 feat(matchmaking): add selectedGame support and implement exitMatchmaker to clear active tickets
Added selectedGame state and UI dropdown

Updated startQueue() to pass { game, mode } metadata

Added exitMatchmaker() to remove existing ticket

Stored active matchmaker ticket in context

Prevents duplicate matchmaker ticket errors
2025-12-01 20:55:24 +05:30
cc1f45457c refactoring game for separate folders for game boards and common logic for player 2025-12-01 20:36:46 +05:30
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
fc29111fe1 Update README.md 2025-12-01 08:30:29 +00:00
5b49e5d584 proper ci cd
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-30 01:23:51 +05:30
55aac72bd2 git gud
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:24:30 +05:30
b10639316e adding port as defaults to 7340
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:15:50 +05:30
d782832fc5 logging connection params
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:08:27 +05:30
b25cd1a039 logging connection params
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 23:06:34 +05:30
e269dfc208 direct push
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-29 19:28:08 +05:30
066f8fbea5 fixes 2025-11-29 19:27:59 +05:30
4a3daf7d8c SSL configuration in Dockerfile and drone.yml
Some checks reported errors
continuous-integration/drone/tag Build was killed
2025-11-29 19:22:06 +05:30
13ad4e08d2 SSL configuration
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-29 19:16:15 +05:30
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
40 changed files with 2963 additions and 401 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

146
.drone.yml Normal file
View File

@@ -0,0 +1,146 @@
---
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
WS_SSL:
from_secret: WS_SSL
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" \
--build-arg VITE_WS_SSL="$WS_SSL" \
-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

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# 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
ARG VITE_WS_SSL
# 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_SSL=${VITE_WS_SSL}
# 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"]

230
README.md
View File

@@ -1,41 +1,225 @@
# Material UI - React Router example in TypeScript # tic-tac-toe-ui — Multiplayer Game Client (React + TypeScript + Vite)
## How to use A fully functional multiplayer Tic-Tac-Toe game client built using **React + TypeScript**, powered by **Nakama WebSocket real-time networking**, and delivered as a tiny **production-optimized Vite build** (served via BusyBox/Docker).
Download the example [or clone the repo](https://github.com/mui/material-ui): This UI communicates with the authoritative backend (`tic-tac-toe`) to deliver a secure, synced, cheat-proof multiplayer experience.
<!-- #target-branch-reference --> ---
```bash ## 🎮 Overview
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-ui-react-router-ts
cd material-ui-react-router-ts This repository contains the front-end implementation of the Tic-Tac-Toe multiplayer platform.
The client supports:
* Device-based authentication
* Full matchmaking lifecycle
* Real-time gameplay with WebSockets
* Authoritative state rendering
* Leaderboard browsing
* Game result screens
* A Vite-powered environment system for dynamic host/SSL selection
This UI is production-ready and deployable to any server or container environment.
---
## ⭐ Features
* **React + TypeScript UI**
* **WebSocket real-time gameplay** using Nakama JS
* **Matchmaking flow:** queue → ticket → match → gameplay
* **Authoritative state updates** (OpCode 2)
* **Secure device authentication** (device UUID → session)
* **Leaderboard view** over Nakama's leaderboard API
* **Production Docker image:** Node → Vite → BusyBox
* **Environment-based configuration** for host/SSL
---
## 🧩 Architecture
### Frontend System Diagram
```mermaid
flowchart LR
User[Browser] --> UI[React + TS + Vite]
UI -->|WebSocket| Nakama
UI -->|HTTP| Nakama
UI --> Leaderboard[Leaderboard API]
UI --> Matchmaking[Matchmaker API]
``` ```
Install it and run: ---
```bash ## 🛠 Tech Stack
npm install
npm run dev * **React 18** (TypeScript)
* **Vite.js** (build system)
* **Nakama JavaScript Client**
* **Plain CSS** for styling
* **WebSockets (SSL / non-SSL selectable)**
* **Docker (multi-stage build)**
---
## 🔧 Environment Variables (Vite)
These are injected at build time:
```
VITE_WS_HOST=nakama.aetoskia.com
VITE_WS_PORT=443
VITE_WS_SKEY=secret
VITE_WS_SSL=true
``` ```
or: Meaning:
<!-- #target-branch-reference --> * **VITE_WS_HOST** → Nakama host (domain or IP)
* **VITE_WS_PORT** → Port for WebSocket/API
* **VITE_WS_SKEY** → Nakama server key
* **VITE_WS_SSL** → `true` for wss://, `false` for ws://
[![Edit on CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/p/sandbox/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts) ---
[![Edit on StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts) ## 🔌 Runtime Flow
## The idea behind the example ### Authentication
<!-- #host-reference --> * UI generates a device UUID
* Calls `client.authenticateDevice()`
* Stores session in React state
This example demonstrates how you can use Material UI with [React Router](https://reactrouter.com/) in [TypeScript](https://github.com/Microsoft/TypeScript). ### Matchmaking
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.
## What's next? 1. User selects mode (classic / blitz)
2. joins the matchmaking queue
3. Waits for matchmaker ticket
4. Auto-joins the match when assigned
<!-- #host-reference --> ### Gameplay
You now have a working example project. * User sends moves via OpCode **1**
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section. * Server validates + broadcasts authoritative board via OpCode **2**
* UI re-renders board state from server packets
### End of Game
* Player sees win/lose/draw
* Can return to home or matchmaking
---
## 🎨 Styling
Styling uses **plain CSS** via a single `styles.css` file.
Simple, responsive layout using Flexbox.
---
## 🐳 Docker (Production Build)
### Dockerfile Overview
```
# 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
ARG VITE_WS_SSL
# 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_SSL=${VITE_WS_SSL}
# 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"]
```
Produces an extremely lightweight production image.
---
## 🧪 Testing
Manual testing validated:
* Full matchmaking loop
* Game state sync
* Invalid move handling (server rejections)
* Disconnect behaviour
* Leaderboard retrieval
Pending:
* Stress tests
* Mobile responsiveness
* Reconnect logic
---
## 📈 Deployment
### Supported:
* Docker on any Linux host
* Raspberry Pi (ARM)
* Google Cloud Run / Compute Engine
* Traefik reverse proxy via `games.aetoskia.com`
### Example Deployment via Docker
```
docker run -d \
-p 3003:3003 \
--restart always \
tic-tac-toe-ui:latest
```
Traefik HTTPS routes:
* **games.aetoskia.com** → UI
---
## 🗺️ Roadmap
* Rematch flow
* Reconnect/resume after refresh
* Improved animations
* Mobile UI redesign
* Centralized error handling
---

View File

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

53
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "tictactoe-vite", "name": "tictactoe-vite",
"version": "1.0.0", "version": "v0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tictactoe-vite", "name": "tictactoe-vite",
"version": "1.0.0", "version": "v0.2.0",
"dependencies": { "dependencies": {
"@emotion/react": "latest", "@emotion/react": "latest",
"@emotion/styled": "latest", "@emotion/styled": "latest",
@@ -14,6 +14,7 @@
"@mui/icons-material": "latest", "@mui/icons-material": "latest",
"@mui/material": "latest", "@mui/material": "latest",
"axios": "latest", "axios": "latest",
"framer-motion": "latest",
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"marked": "latest", "marked": "latest",
"react": "latest", "react": "latest",
@@ -2064,6 +2065,33 @@
"node": ">= 6" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3263,6 +3291,21 @@
"node": ">= 0.6" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3761,6 +3804,12 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",

View File

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

165
src/App.tsx Normal file
View File

@@ -0,0 +1,165 @@
import React, { useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import Player from "./Player";
import TicTacToeGame from "./games/tictactoe/TicTacToeGame";
import { TicTacToeGameProps } from "./games/tictactoe/props";
import BattleshipGame from "./games/battleship/BattleshipGame"
import { BattleshipGameProps } from "./games/battleship/props";
import { GameState } from "./interfaces/states";
import { GameProps } from "./interfaces/props";
const INITIAL_GAME_STATE: GameState = {
boards: {},
turn: 0,
winner: null,
gameOver: false,
players: [],
metadata: {},
};
export default function App() {
// unified game state
const [game, setGame] = useState<GameState>(INITIAL_GAME_STATE);
const { onMatchData, matchId, session } = useNakama();
const commonProps: GameProps = {
boards: game.boards,
turn: game.turn,
winner: game.winner,
gameOver: game.gameOver,
players: game.players,
myUserId: session?.user_id ?? null,
};
const ticTacToeProps: TicTacToeGameProps = {
...commonProps,
};
const battleshipProps: BattleshipGameProps = {
...commonProps,
metadata: game.metadata,
};
// ---------------------------------------------------
// RENDER GAME BOARD
// ---------------------------------------------------
function renderGameBoard() {
if (!matchId || !game.metadata?.game) return null;
switch (game.metadata.game) {
case "tictactoe":
return (
<TicTacToeGame
{...ticTacToeProps}
/>
);
case "battleship":
return (
<BattleshipGame
{...battleshipProps}
/>
);
default:
return <div>Unknown game: {game.metadata.game}</div>;
}
}
// ------------------------------------------
// 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);
setGame({
boards: state.boards,
turn: state.turn,
gameOver: state.game_over,
winner:
state.winner >= 0 ? state.players[state.winner].username : null,
players: state.players ?? [],
metadata: state.metadata ?? {},
});
}
}
// ---------------------------------------------------
// EFFECTS
// ---------------------------------------------------
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "auto";
};
}, []);
useEffect(() => {
onMatchData(onMatchDataCallback);
}, [onMatchData]);
// ---------------------------------------------------
// UI LAYOUT
// ---------------------------------------------------
return (
<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",
}}
>
Games
</header>
{/* ---------------- 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",
}}
>
{renderGameBoard()}
</div>
</motion.div>
</div>
);
}

319
src/Player.tsx Normal file
View File

@@ -0,0 +1,319 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "./providers/NakamaProvider";
import { PlayerProps } from "./interfaces/props";
export default function Player({
onMatchDataCallback,
}: PlayerProps) {
const {
session,
matchId,
loginOrRegister,
logout,
onMatchData,
joinMatchmaker,
exitMatchmaker,
} = useNakama();
const [username, setUsername] = useState(
localStorage.getItem("username") ?? ""
);
const [selectedGame, setSelectedGame] = useState("tictactoe");
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(
selectedGame: string,
selectedMode: string
) {
setIsQueueing(true);
const gameMetadata = {
game: selectedGame,
mode: selectedMode,
}
try {
const ticket = await joinMatchmaker(gameMetadata);
console.log("Queued:", ticket);
} catch (err) {
console.error("Matchmaking failed:", err);
setIsQueueing(false);
}
}
async function cancelQueue(
selectedGame: string,
selectedMode: string
) {
setIsQueueing(false);
const gameMetadata = {
game: selectedGame,
mode: selectedMode,
}
await exitMatchmaker(gameMetadata)
}
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={selectedGame}
disabled={isQueueing}
onChange={(e) => setSelectedGame(e.target.value)}
style={{
padding: "8px",
margin: "10px 0 16px",
width: "60%",
borderRadius: "10px",
background: "#222",
color: "white",
border: "1px solid #333",
}}
>
<option value="tictactoe">Tic Tac Toe</option>
<option value="battleship">Battleship</option>
</select>
{/*<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(
selectedGame,
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(
selectedGame,
selectedMode,
)}
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" }}>
</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

@@ -0,0 +1,132 @@
import React, { useMemo } from "react";
import { motion } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider";
import PlacementGrid from "./placement/PlacementGrid";
import ShotGrid from "./battle/ShotGrid";
import { BattleshipGameProps } from "./props";
import { BattleshipPayload } from "./models";
import {
placePayload,
shootPayload,
} from "./utils";
const Fleet: Record<string, number> = {
carrier: 5,
battleship: 4,
cruiser: 3,
submarine: 3,
destroyer: 2,
};
const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"];
export default function BattleshipGame({
boards,
players,
myUserId,
turn,
winner,
gameOver,
metadata,
}: BattleshipGameProps) {
const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex((p) => p.user_id === myUserId);
const oppIndex = myIndex === 0 ? 1 : 0;
const phase = metadata["phase"] ?? "lobby";
const isMyTurn = phase === "battle" && turn === myIndex;
const myShips = boards[`p${myIndex}_ships`]?.grid ?? [[]];
const myShots = boards[`p${myIndex}_shots`] ?.grid ?? [[]];
const placed = metadata[`p${myIndex}_placed`] ?? 0;
const nextShip = FLEET_ORDER[placed] || null;
const nextShipSize = nextShip ? Fleet[nextShip] : null;
function handleMove(matchPayload: BattleshipPayload) {
if (!matchId) return;
sendMatchData(matchId!, 1, matchPayload);
}
// ------------------- STATUS LABEL -------------------
const status = useMemo(() => {
if (phase === "lobby") return `In Lobby`;
if (winner !== null) return `Winner: Player ${winner}`;
if (gameOver) return "Game over — draw";
if (phase === "placement") return `Place your ${nextShip ?? ""}`;
if (myIndex === -1) return "Spectating";
if (!isMyTurn) return "Opponents turn";
return "Your turn";
}, [winner, gameOver, phase, isMyTurn, myIndex, nextShip]);
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 8 }}>{status}</h2>
{/* ---------------- PHASE 1: PLACEMENT ---------------- */}
{phase === "placement" && nextShip && (
<PlacementGrid
shipBoard={myShips}
shipName={nextShip}
shipSize={nextShipSize}
onPlace={(
s,r,c,d
) => handleMove(
placePayload(s,r,c,d)
)}
/>
)}
{/* ---------------- PHASE 2: BATTLE ---------------- */}
{phase === "battle" && (
<>
<h3>Your Shots</h3>
<ShotGrid
grid={myShots}
isMyTurn={isMyTurn}
gameOver={!!gameOver}
onShoot={(
r,c
) => handleMove(
shootPayload(r,c)
)}
/>
<h3 style={{ marginTop: "18px" }}>Your Ships</h3>
<PlacementGrid
shipBoard={myShips}
shipName="readonly"
shipSize={0}
onPlace={() => {}}
/>
</>
)}
{/* ---------------- WINNER UI ---------------- */}
{winner !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, scale: [1, 1.05, 1] }}
transition={{ repeat: Infinity, duration: 1.4 }}
style={{
marginTop: 12,
fontSize: "20px",
fontWeight: "bold",
color: "#f1c40f",
}}
>
🎉 Player {winner} Wins! 🎉
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
interface ShotGridProps {
grid: string[][]; // your shots: "", "H", "M"
isMyTurn: boolean; // only clickable on your turn
gameOver: boolean;
onShoot: (row: number, col: number) => void;
}
export default function ShotGrid({
grid,
isMyTurn,
gameOver,
onShoot,
}: ShotGridProps) {
const rows = grid.length;
const cols = grid[0].length;
function handleClick(r: number, c: number) {
if (!isMyTurn || gameOver) return;
if (grid[r][c] !== "") return; // can't shoot twice
onShoot(r, c);
}
return (
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 36px)`,
gap: "4px",
justifyContent: "center",
marginTop: "12px",
}}
>
{grid.map((row, r) =>
row.map((cell, c) => {
const empty = cell === "";
const hit = cell === "H";
const miss = cell === "M";
let bg = "#111";
if (hit) bg = "#e74c3c"; // red for hit
if (miss) bg = "#34495e"; // gray for miss
return (
<motion.button
key={`${r}-${c}`}
whileHover={empty && isMyTurn && !gameOver ? { scale: 1.1 } : {}}
whileTap={empty && isMyTurn && !gameOver ? { scale: 0.9 } : {}}
onClick={() => handleClick(r, c)}
style={{
width: 36,
height: 36,
borderRadius: 4,
border: "1px solid #444",
background: bg,
cursor:
empty && isMyTurn && !gameOver ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: 700,
}}
>
<AnimatePresence>
{hit && (
<motion.span
key="hit"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.2, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
>
H
</motion.span>
)}
{miss && (
<motion.span
key="miss"
initial={{ scale: 0.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.2, opacity: 0 }}
transition={{ type: "spring", stiffness: 200, damping: 10 }}
>
M
</motion.span>
)}
</AnimatePresence>
</motion.button>
);
})
)}
</motion.div>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { motion } from "framer-motion";
interface ShipSelectorProps {
remainingShips: string[]; // ex: ["carrier", "battleship", ...]
selectedShip: string | null;
orientation: "h" | "v";
onSelectShip: (ship: string) => void;
onToggleOrientation: () => void;
}
export default function ShipSelector({
remainingShips,
selectedShip,
orientation,
onSelectShip,
onToggleOrientation,
}: ShipSelectorProps) {
return (
<div style={{ marginTop: 12, textAlign: "center" }}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.9 }}
style={{
marginBottom: 10,
fontSize: 16,
color: "#ddd",
}}
>
Select ship & orientation
</motion.div>
{/* SHIP BUTTONS */}
<div
style={{
display: "flex",
justifyContent: "center",
gap: "10px",
flexWrap: "wrap",
marginBottom: 14,
}}
>
{remainingShips.map((ship) => {
const active = ship === selectedShip;
return (
<motion.button
key={ship}
onClick={() => onSelectShip(ship)}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
style={{
padding: "8px 14px",
borderRadius: 8,
background: active ? "#f1c40f" : "#333",
color: active ? "#000" : "#fff",
border: "2px solid #444",
cursor: "pointer",
fontSize: 14,
}}
>
{ship.toUpperCase()}
</motion.button>
);
})}
</div>
{/* ORIENTATION BUTTON */}
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.92 }}
onClick={onToggleOrientation}
style={{
padding: "8px 14px",
borderRadius: 8,
background: "#222",
color: "white",
border: "2px solid #444",
cursor: "pointer",
fontSize: 14,
}}
>
Orientation: <strong>{orientation === "h" ? "Horizontal" : "Vertical"}</strong>
</motion.button>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { motion } from "framer-motion";
interface StatusBarProps {
text: string;
}
export default function StatusBar({ text }: StatusBarProps) {
return (
<motion.div
key={text}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{
marginBottom: 8,
fontSize: "18px",
textAlign: "center",
}}
>
{text}
</motion.div>
);
}

View File

@@ -0,0 +1,15 @@
import {
MatchDataModel,
} from '../../interfaces/models'
export interface BattleshipPayload {
action: "place" | "shoot"; // extend as needed
data: {
ship?: string; // only for placement
row: number;
col: number;
dir?: "h" | "v";
};
}
export type BattleshipMatchDataModel = MatchDataModel<BattleshipPayload>;

View File

@@ -0,0 +1,169 @@
import React from "react";
import { motion } from "framer-motion";
interface BattleProps {
myIndex: number;
boards: Record<string, { grid: string[][] }>;
turn: number;
winner: string | null;
gameOver: boolean | null;
players: any[];
myUserId: string | null;
onShoot: (row: number, col: number) => void;
}
export default function BattlePhase({
myIndex,
boards,
turn,
winner,
gameOver,
players,
myUserId,
onShoot,
}: BattleProps) {
if (myIndex < 0) return <div>Spectating...</div>;
const myShots = boards[`p${myIndex}_shots`]?.grid;
const myShips = boards[`p${myIndex}_ships`]?.grid;
const isMyTurn = turn === myIndex;
const gameReady = players.length === 2;
if (!myShots || !myShips) return <div>Loading...</div>;
const renderShotCell = (cell: string, r: number, c: number) => {
const disabled =
!isMyTurn ||
gameOver ||
cell === "H" ||
cell === "M"; // can't shoot same cell
const bg =
cell === "H"
? "#e74c3c" // red = hit
: cell === "M"
? "#34495e" // blue/gray = miss
: "#1c1c1c"; // untouched
return (
<motion.div
key={`shot-${r}-${c}`}
onClick={() => !disabled && onShoot(r, c)}
whileHover={!disabled ? { scale: 1.06 } : {}}
whileTap={!disabled ? { scale: 0.85 } : {}}
style={{
width: 38,
height: 38,
border: "1px solid #333",
background: bg,
cursor: disabled ? "not-allowed" : "pointer",
}}
/>
);
};
const renderShipCell = (cell: string, r: number, c: number) => {
const bg =
cell === "S"
? "#2980b9" // ship
: cell === "X"
? "#c0392b" // destroyed
: "#111"; // water
return (
<motion.div
key={`ship-${r}-${c}`}
style={{
width: 38,
height: 38,
border: "1px solid #222",
background: bg,
}}
/>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 10 }}>
{gameOver
? winner === myUserId
? "🎉 Victory! 🎉"
: "💥 Defeat 💥"
: isMyTurn
? "Your Turn — Fire!"
: "Opponent's Turn"}
</h2>
{/* -------------------------
TOP SECTION — YOUR SHOTS
-------------------------- */}
<div style={{ marginBottom: 12, opacity: 0.85 }}>
<strong>Your Shots</strong>
</div>
<motion.div
style={{
display: "grid",
gridTemplateColumns: `repeat(${myShots[0].length}, 38px)`,
gap: 4,
justifyContent: "center",
}}
>
{myShots.map((row, r) =>
row.map((cell, c) => renderShotCell(cell, r, c))
)}
</motion.div>
{/* -------------------------
BOTTOM SECTION — YOUR FLEET
-------------------------- */}
<div style={{ marginTop: 28, marginBottom: 12, opacity: 0.85 }}>
<strong>Your Fleet Status</strong>
</div>
<motion.div
style={{
display: "grid",
gridTemplateColumns: `repeat(${myShips[0].length}, 38px)`,
gap: 4,
justifyContent: "center",
}}
>
{myShips.map((row, r) =>
row.map((cell, c) => renderShipCell(cell, r, c))
)}
</motion.div>
{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: "18px",
fontWeight: 700,
}}
>
{winner === players[myIndex].user_id
? "🎉 You Win! 🎉"
: "💥 You Lost 💥"}
</motion.div>
)}
</motion.div>
);
}

View File

@@ -0,0 +1,100 @@
import React from "react";
import { motion, AnimatePresence } from "framer-motion";
interface BattleStatusProps {
phase: string;
myIndex: number;
turn: number;
players: any[];
lastHit: boolean | null;
winner: string | null;
gameOver: boolean | null;
}
export default function BattleStatus({
phase,
myIndex,
turn,
players,
lastHit,
winner,
gameOver,
}: BattleStatusProps) {
const isMyTurn = turn === myIndex;
// -----------------------------
// STATUS TEXT
// -----------------------------
let statusText = "";
if (gameOver) {
statusText =
winner === players[myIndex]?.user_id ? "🎉 Victory!" : "💥 Defeat";
} else if (phase === "placement") {
statusText = "Place Your Fleet";
} else {
statusText = isMyTurn ? "Your Turn — FIRE!" : "Opponents Turn";
}
// -----------------------------
// Last hit/miss indicator
// -----------------------------
let hitText = null;
if (lastHit === true) hitText = "🔥 HIT!";
else if (lastHit === false) hitText = "💦 MISS";
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{
textAlign: "center",
marginBottom: 14,
color: "#eee",
fontFamily: "sans-serif",
}}
>
{/* MAIN STATUS */}
<motion.div
key={statusText}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
style={{
fontSize: 20,
fontWeight: 700,
marginBottom: 4,
}}
>
{statusText}
</motion.div>
{/* HIT / MISS FEEDBACK */}
<AnimatePresence mode="wait">
{hitText && !gameOver && (
<motion.div
key={hitText}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.25 }}
style={{
color: lastHit ? "#e74c3c" : "#3498db",
fontSize: 18,
fontWeight: 600,
marginTop: 4,
}}
>
{hitText}
</motion.div>
)}
</AnimatePresence>
{/* PHASE */}
<div style={{ marginTop: 6, opacity: 0.55, fontSize: 14 }}>
Phase: <strong>{phase}</strong>
</div>
</motion.div>
);
}

View File

@@ -0,0 +1,145 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
const fleet = [
{ name: "carrier", size: 5 },
{ name: "battleship", size: 4 },
{ name: "cruiser", size: 3 },
{ name: "submarine", size: 3 },
{ name: "destroyer", size: 2 },
];
interface PlacementProps {
myIndex: number;
boards: Record<string, { grid: string[][] }>;
metadata: Record<string, any>;
onPlace: (ship: string, row: number, col: number, dir: string) => void;
players: any[];
}
export default function PlacementPhase({
myIndex,
boards,
metadata,
onPlace,
}: PlacementProps) {
const shipBoard = boards[`p${myIndex}_ships`]?.grid;
const placedCount = metadata[`p${myIndex}_placed`] ?? 0;
const [selectedShip, setSelectedShip] = useState<string | null>(null);
const [orientation, setOrientation] = useState<"h" | "v">("h");
const [hoverPos, setHoverPos] = useState<[number, number] | null>(null);
if (!shipBoard) return <div>Loading...</div>;
const remainingShips = fleet.slice(placedCount);
const current = remainingShips[0];
const shipName = current?.name ?? null;
const shipSize = current?.size ?? 0;
const canPlace = (r: number, c: number) => {
if (!shipName) return false;
// bounds
if (orientation === "h") {
if (c + shipSize > shipBoard[0].length) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[r][c + i] !== "") return false;
}
} else {
if (r + shipSize > shipBoard.length) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[r + i][c] !== "") return false;
}
}
return true;
};
const renderCell = (cell: string, r: number, c: number) => {
const hovered = hoverPos?.[0] === r && hoverPos?.[1] === c;
const placing = hovered && shipName;
let previewColor = "transparent";
if (placing) {
const valid = canPlace(r, c);
previewColor = valid ? "rgba(46, 204, 113, 0.4)" : "rgba(231, 76, 60, 0.4)";
}
return (
<motion.div
key={`${r}-${c}`}
onMouseEnter={() => setHoverPos([r, c])}
onMouseLeave={() => setHoverPos(null)}
onClick={() => {
if (shipName && canPlace(r, c)) {
onPlace(shipName, r, c, orientation);
}
}}
whileHover={{ scale: 1.03 }}
style={{
width: 40,
height: 40,
border: "1px solid #333",
background: cell === "S" ? "#2980b9" : previewColor,
cursor: shipName ? "pointer" : "default",
}}
/>
);
};
return (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{ textAlign: "center" }}
>
<h2 style={{ marginBottom: 10 }}>Place Your Fleet</h2>
{shipName ? (
<div style={{ marginBottom: 12 }}>
<strong>Placing:</strong> {shipName} ({shipSize})
</div>
) : (
<div style={{ marginBottom: 12, color: "#2ecc71" }}>
All ships placed waiting for opponent...
</div>
)}
{shipName && (
<div style={{ marginBottom: 12 }}>
<button
onClick={() => setOrientation(orientation === "h" ? "v" : "h")}
style={{
padding: "8px 14px",
background: "#111",
border: "1px solid #666",
borderRadius: 6,
color: "white",
}}
>
Orientation: {orientation === "h" ? "Horizontal" : "Vertical"}
</button>
</div>
)}
{/* BOARD GRID */}
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${shipBoard[0].length}, 40px)`,
gap: 4,
justifyContent: "center",
marginTop: 20,
}}
>
{shipBoard.map((row, r) =>
row.map((cell, c) => renderCell(cell, r, c))
)}
</motion.div>
</motion.div>
);
}

View File

@@ -0,0 +1,127 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
interface ShipGridProps {
shipBoard: string[][]; // current placed ships
shipName: string | null; // current ship being placed
shipSize: number | null;
onPlace: (ship: string, row: number, col: number, dir: "h" | "v") => void;
}
export default function PlacementGrid({
shipBoard,
shipName,
shipSize,
onPlace,
}: ShipGridProps) {
const [dir, setDir] = useState<"h" | "v">("h");
const [hoverR, setHoverR] = useState<number | null>(null);
const [hoverC, setHoverC] = useState<number | null>(null);
const rows = shipBoard.length;
const cols = shipBoard[0].length;
function isPreviewCell(r: number, c: number): boolean {
if (hoverR === null || hoverC === null || !shipSize) return false;
if (dir === "h") {
return r === hoverR && c >= hoverC && c < hoverC + shipSize;
} else {
return c === hoverC && r >= hoverR && r < hoverR + shipSize;
}
}
function isPreviewValid(): boolean {
if (hoverR === null || hoverC === null || !shipSize) return false;
if (dir === "h") {
if (hoverC + shipSize > cols) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[hoverR][hoverC + i] !== "") return false;
}
} else {
if (hoverR + shipSize > rows) return false;
for (let i = 0; i < shipSize; i++) {
if (shipBoard[hoverR + i][hoverC] !== "") return false;
}
}
return true;
}
function handleClick() {
if (shipName && shipSize && hoverR !== null && hoverC !== null) {
if (isPreviewValid()) {
onPlace(shipName, hoverR, hoverC, dir);
}
}
}
return (
<div style={{ textAlign: "center" }}>
{/* Ship rotation button */}
<button
onClick={() => setDir(dir === "h" ? "v" : "h")}
style={{
padding: "6px 12px",
marginBottom: "10px",
background: "#333",
color: "white",
borderRadius: 6,
cursor: "pointer",
}}
>
Rotate Ship ({dir.toUpperCase()})
</button>
{/* GRID */}
<motion.div
layout
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 36px)`,
gap: "4px",
justifyContent: "center",
}}
onClick={handleClick}
>
{shipBoard.map((row, r) =>
row.map((cell, c) => {
const preview = isPreviewCell(r, c);
const valid = isPreviewValid();
let bg = "#0a0a0a";
if (cell === "S") {
bg = "#3498db"; // placed ship
} else if (preview) {
bg = valid ? "rgba(46, 204, 113, 0.6)" : "rgba(231, 76, 60, 0.6)";
}
return (
<motion.div
key={`${r}-${c}`}
whileHover={{ scale: 1.05 }}
onMouseEnter={() => {
setHoverR(r);
setHoverC(c);
}}
onMouseLeave={() => {
setHoverR(null);
setHoverC(null);
}}
style={{
width: 36,
height: 36,
border: "1px solid #333",
background: bg,
borderRadius: 4,
}}
/>
);
})
)}
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import {
GameProps,
} from '../../interfaces/props'
export interface BattleshipGameProps extends GameProps {
metadata: Record<string, any>;
}

View File

@@ -0,0 +1,22 @@
import {
BattleshipPayload
} from "./models";
export function placePayload(
ship: string,
row: number,
col: number,
dir: "h" | "v"
): BattleshipPayload {
return {
action: "place",
data: { ship, row, col, dir }
};
}
export function shootPayload(row: number, col: number): BattleshipPayload {
return {
action: "shoot",
data: { row, col }
};
}

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useNakama } from "../../providers/NakamaProvider";
import getHaiku from "../../utils/haikus";
import { TicTacToeGameProps } from "./props";
import { TicTacToePayload } from "./models";
import { movePayload } from "./utils";
export default function TicTacToeGame({
boards,
turn,
winner,
gameOver,
players,
myUserId,
}: TicTacToeGameProps) {
const { sendMatchData, matchId } = useNakama();
const myIndex = players.findIndex(p => p.user_id === myUserId);
const gameReady = players.length === 2;
const mySymbol =
myIndex !== null && players[myIndex]
? players[myIndex].metadata?.symbol ?? null
: null;
const opponentSymbol =
myIndex !== null && players.length === 2
? players[1 - myIndex].metadata?.symbol ?? null
: 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 (gameOver) {
status = `Draw!!!`;
} 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;
const board = boards['tictactoe']?.grid ?? null;
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]);
function handleMove(matchPayload: TicTacToePayload) {
if (!matchId) return;
sendMatchData(matchId!, 1, matchPayload);
}
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
-------------------------- */}
{!board && <div style={{ textAlign: "center", marginTop: "14px" }}>Loading...</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 && handleMove(
movePayload(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={{
minHeight: "90px",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
marginTop: "14px",
position: "relative",
}}
>
<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 TicTacToeLeaderboard({
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,11 @@
import {
MatchDataModel,
} from '../../interfaces/models'
export interface TicTacToePayload {
data: {
row: number;
col: number;
};
}
export type TicTacToeMatchDataModel = MatchDataModel<TicTacToePayload>;

View File

@@ -0,0 +1,8 @@
import {
GameProps,
} from '../../interfaces/props'
export interface TicTacToeGameProps extends GameProps {
// metadata: Record<string, any>;
}

View File

@@ -0,0 +1,12 @@
import {
TicTacToePayload
} from "./models";
export function movePayload(
row: number,
col: number,
): TicTacToePayload {
return {
data: { row, col }
};
}

View File

@@ -0,0 +1,38 @@
import {
Client,
Session,
Socket,
} from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import {
GameMetadataModel,
MatchDataModel,
} from './models'
export interface NakamaContextType {
client: Client;
socket: Socket | null;
session: Session | null;
matchId: string | null;
loginOrRegister(username?: string): Promise<void>;
logout(): Promise<void>;
joinMatchmaker(gameMetadata: GameMetadataModel): Promise<string>;
exitMatchmaker(gameMetadata: GameMetadataModel): Promise<void>;
joinMatch(matchId: string): Promise<void>;
sendMatchData(matchId: string, op: number, data: object): void;
onMatchData(cb: (msg: MatchDataModel) => void): void;
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
listOpenMatches(): Promise<ApiMatch[]>;
}

21
src/interfaces/models.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface PlayerModel {
user_id: string;
username: string;
index: number;
metadata: Record<string, string>; // e.g. { symbol: "X" }
}
export interface MatchDataModel<T = any> {
opCode: number;
data: T;
userId: string | null;
}
export interface BoardModel {
grid: string[][];
}
export interface GameMetadataModel {
game: string;
mode: string;
}

19
src/interfaces/props.ts Normal file
View File

@@ -0,0 +1,19 @@
import {
MatchDataModel,
} from './models'
import {
GameState
} from "./states";
export interface PlayerProps {
onMatchDataCallback: (msg:MatchDataModel) => void;
}
export interface GameProps
extends Pick<
GameState,
"boards" | "turn" | "winner" | "gameOver" | "players"
> {
myUserId: string | null;
}

15
src/interfaces/refs.ts Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import {
Socket
} from "@heroiclabs/nakama-js";
import {
GameMetadataModel,
} from './models'
export interface NakamaRefs {
socketRef: React.RefObject<Socket | null>;
gameMetadataRef: React.RefObject<GameMetadataModel | null>;
}

26
src/interfaces/states.ts Normal file
View File

@@ -0,0 +1,26 @@
import {
Session,
Socket
} from "@heroiclabs/nakama-js";
import {
BoardModel,
PlayerModel,
} from "./models"
export interface NakamaProviderState {
session: Session | null;
socket: Socket | null;
matchId: string | null;
matchmakerTicket: string | null;
}
export interface GameState {
boards: Record<string, BoardModel>;
turn: number;
winner: string | null;
gameOver: boolean;
players: PlayerModel[];
metadata: Record<string, any>;
}

View File

@@ -1,14 +1,14 @@
import * as React from 'react'; import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import TicTacToe from './tictactoe/TicTacToe'; import App from './App';
import { NakamaProvider } from './tictactoe/providers/NakamaProvider'; import { NakamaProvider } from './providers/NakamaProvider';
import "./tictactoe/styles.css"; import "./styles.css";
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
root.render( root.render(
<NakamaProvider> <NakamaProvider>
<TicTacToe /> <App />
</NakamaProvider>, </NakamaProvider>,
); );

View File

@@ -0,0 +1,343 @@
import React, {
createContext,
useContext,
useState,
useRef
} from "react";
import {
Client,
Socket,
MatchmakerTicket,
MatchData,
MatchmakerMatched,
} from "@heroiclabs/nakama-js";
import {
ApiMatch,
ApiLeaderboardRecordList,
// @ts-ignore
} from "@heroiclabs/nakama-js/dist/api.gen"
import { NakamaContextType } from "../interfaces/contexts";
import { NakamaRefs } from "../interfaces/refs";
import { NakamaProviderState } from "../interfaces/states";
import { GameMetadataModel, MatchDataModel } from "../interfaces/models";
function getOrCreateDeviceId(): string {
const key = "nakama.deviceId";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
export const NakamaContext = createContext<NakamaContextType>(null!);
export function NakamaProvider({ children }: { children: React.ReactNode }) {
console.log(
"[Nakama] Initializing...",
// import.meta.env.VITE_WS_SKEY,
import.meta.env.VITE_WS_HOST,
import.meta.env.VITE_WS_PORT,
import.meta.env.VITE_WS_SSL === "true"
);
const [client] = useState(
() => new Client(
import.meta.env.VITE_WS_SKEY,
import.meta.env.VITE_WS_HOST,
import.meta.env.VITE_WS_PORT,
import.meta.env.VITE_WS_SSL === "true"
)
);
// --------------------------------------
// INTERNAL STATE (React state)
// --------------------------------------
const [internal, setInternal] = useState<NakamaProviderState>({
session: null,
socket: null,
matchId: null,
matchmakerTicket: null,
});
// --------------------------------------
// INTERNAL REFS (non-reactive, stable)
// --------------------------------------
const refs: NakamaRefs = {
socketRef: useRef<Socket | null>(null),
gameMetadataRef: useRef<GameMetadataModel | null>(null),
};
// Helpers to update internal state cleanly
function updateState(values: Partial<NakamaProviderState>) {
setInternal(prev => ({ ...prev, ...values }));
}
// ---------------------------------------
// LOGIN FLOW
// ---------------------------------------
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) {
// authenticate user
const newSession = await getSession(username);
updateState({ session: newSession });
const s = client.createSocket(import.meta.env.VITE_WS_SSL === "true");
await s.connect(newSession, true);
updateState({ socket: s });
refs.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 (refs.gameMetadataRef.current) {
try {
await joinMatchmaker(refs.gameMetadataRef.current);
} catch (e) {
console.error("[Nakama] Requeue failed:", e);
}
}
return;
}
// 2) Valid match: continue as usual.
console.log("[Nakama] MATCHED:", matched);
try {
await s.joinMatch(matched.match_id);
updateState({ matchId: matched.match_id });
console.log("[Nakama] Auto-joined match:", matched.match_id);
} catch (err) {
console.error("[Nakama] Failed to join match:", err);
}
};
}
// ---------------------------------------
// LOGOUT
// ---------------------------------------
async function logout() {
try {
if (refs.socketRef.current) {
refs.socketRef.current.disconnect(true);
console.log("[Nakama] WebSocket disconnected");
}
} catch (err) {
console.warn("[Nakama] Error while disconnecting socket:", err);
}
updateState({
session: null,
socket: null,
matchId: null,
matchmakerTicket: null,
});
refs.socketRef.current = null;
console.log("[Nakama] Clean logout completed");
}
// ----------------------------------------------------
// MATCHMAKING
// ----------------------------------------------------
async function joinMatchmaker(gameMetadata: GameMetadataModel) {
const socket = refs.socketRef.current;
if (!socket) throw new Error("Socket missing");
const { game, mode } = gameMetadata;
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
{ game, mode }
);
refs.gameMetadataRef.current = { game, mode };
updateState({ matchmakerTicket: ticket.ticket });
return ticket.ticket;
}
async function exitMatchmaker() {
const socket = refs.socketRef.current;
const { matchmakerTicket } = internal;
if (!socket) throw new Error("Socket missing");
if (matchmakerTicket) {
await socket.removeMatchmaker(matchmakerTicket);
}
updateState({ matchmakerTicket: null });
}
// ----------------------------------------------------
// EXPLICIT MATCH JOIN
// ----------------------------------------------------
async function joinMatch(id: string) {
if (!internal.socket) throw new Error("Socket missing");
await internal.socket.joinMatch(id);
updateState({ matchId: id });
console.log("[Nakama] Joined match", id);
}
// ----------------------------------------------------
// MATCH STATE SEND
// ----------------------------------------------------
function sendMatchData(matchId: string, op: number, data: object) {
if (!internal.socket) return;
console.log("[Nakama] Sending match state:", matchId, op, data);
internal.socket.sendMatchState(matchId, op, JSON.stringify(data));
}
// ----------------------------------------------------
// MATCH DATA LISTENER
// ----------------------------------------------------
function onMatchData(cb: (msg: MatchDataModel) => void) {
if (!internal.socket) return;
internal.socket.onmatchdata = (m: MatchData) => {
const decoded = JSON.parse(new TextDecoder().decode(m.data));
cb({
opCode: m.op_code,
data: decoded,
userId: m.presence?.user_id ?? null,
});
};
}
// ---------------------------------------
// LEADERBOARD + LIST MATCHES
// ---------------------------------------
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
if (!internal.session) return [];
return await client.listLeaderboardRecords(
internal.session,
"tictactoe",
[],
10 // top 10
);
}
async function listOpenMatches(): Promise<ApiMatch[]> {
if (!internal.session) {
console.warn("[Nakama] listOpenMatches called before login");
return [];
}
const result = await client.listMatches(internal.session, 10);
console.log("[Nakama] Open matches:", result.matches);
return result.matches ?? [];
}
// ---------------------------------------
// PROVIDER VALUE
// ---------------------------------------
return (
<NakamaContext.Provider
value={{
client,
session: internal.session,
socket: internal.socket,
matchId: internal.matchId,
loginOrRegister,
logout,
joinMatchmaker,
exitMatchmaker,
joinMatch,
sendMatchData,
onMatchData,
getLeaderboardTop,
listOpenMatches,
}}
>
{children}
</NakamaContext.Provider>
);
}
// ---------------------------------------------
// USE HOOK
// ---------------------------------------------
export function useNakama(): NakamaContextType {
const ctx = useContext(NakamaContext);
if (!ctx) throw new Error("useNakama must be inside a NakamaProvider");
return ctx;
}

133
src/styles.css Normal file
View File

@@ -0,0 +1,133 @@
body {
font-family: sans-serif;
background: #eaeef3;
display: flex;
justify-content: center;
margin: 0;
color: #222;
}
.game-container {
text-align: center;
padding: 20px;
}
.board {
width: 300px;
height: 300px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin: 20px auto;
}
.square {
background: white;
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: #f1f1f1;
transform: scale(1.04);
}
.status {
font-size: 1.6rem;
margin-bottom: 15px;
font-weight: bold;
color: #222;
}
.reset-btn {
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: #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

@@ -1,48 +0,0 @@
import React from "react";
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 (
<div>
{winner ? (
<h2>Winner: {winner}</h2>
) : (
<h2>Turn: Player {turn === 0 ? "X" : "O"}</h2>
)}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 80px)",
gap: "10px",
marginTop: "20px",
}}
>
{board.map((row, rIdx) =>
row.map((cell, cIdx) => (
<button
key={`${rIdx}-${cIdx}`}
style={{
width: "80px",
height: "80px",
fontSize: "2rem",
cursor: cell || winner ? "not-allowed" : "pointer",
}}
onClick={() => {
if (!cell && !winner) onCellClick(rIdx, cIdx);
}}
>
{cell}
</button>
))
)}
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
interface SquareProps {
value: string;
onClick: () => void;
}
export default function Square({ value, onClick } : SquareProps) {
return (
<button className="square" onClick={onClick}>
{value}
</button>
);
}

View File

@@ -1,98 +0,0 @@
import { useState, useEffect } from "react";
import { useNakama } from "./providers/NakamaProvider";
import Board from "./Board";
export default function TicTacToe() {
const [username, setUsername] = useState("");
const [board, setBoard] = useState<string[][]>([
["", "", ""],
["", "", ""],
["", "", ""]
]);
const [turn, setTurn] = useState<number>(0);
const [winner, setWinner] = useState<string | null>(null);
const {
loginOrRegister,
joinMatchmaker,
onMatchData,
sendMatchData,
matchId,
} = useNakama();
useEffect(() => {
onMatchData((msg) => {
console.log("[Match Data]", msg);
if (msg.opCode === 2) {
setBoard(msg.data.board);
setTurn(msg.data.turn);
setWinner(msg.data.winner || null);
}
});
}, [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
// ------------------------------------------
function handleCellClick(row: number, col: number) {
if (!matchId) return;
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>
{!matchId && (
<>
<input
placeholder="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<button onClick={connect}>Connect</button>
<button onClick={startQueue}>Join Matchmaking</button>
</>
)}
{matchId && (
<Board
board={board}
turn={turn}
winner={winner}
onCellClick={handleCellClick}
/>
)}
</div>
);
}

View File

@@ -1,157 +0,0 @@
import {
Client,
Session,
Socket,
MatchmakerTicket,
Match,
MatchData,
MatchmakerMatched,
} from "@heroiclabs/nakama-js";
import React, { createContext, useContext, useState } from "react";
function getOrCreateDeviceId(): string {
const key = "nakama.deviceId";
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
export interface NakamaContextType {
client: Client;
socket: Socket | null;
session: Session | null;
matchId: string | null;
loginOrRegister(username: string): 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;
}
export const NakamaContext = createContext<NakamaContextType>(null!);
export function NakamaProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() => new Client("defaultkey", "127.0.0.1", "7350")
);
const [session, setSession] = useState<Session | null>(null);
const [socket, setSocket] = useState<Socket | null>(null);
const [matchId, setMatchId] = useState<string | null>(null);
// ----------------------------------------------------
// LOGIN
// ----------------------------------------------------
async function loginOrRegister(username: string) {
const deviceId = getOrCreateDeviceId();
// authenticate user
const newSession = await client.authenticateDevice(deviceId, true, username);
setSession(newSession);
// create socket (new Nakama 3.x signature)
const s = client.createSocket(undefined, undefined); // no SSL on localhost
await s.connect(newSession, true);
setSocket(s);
console.log("[Nakama] WebSocket connected");
// MATCHMAKER MATCHED CALLBACK
s.onmatchmakermatched = async (matched: MatchmakerMatched) => {
console.log("[Nakama] MATCHED:", matched);
setMatchId(matched.match_id);
try {
await s.joinMatch(matched.match_id);
console.log("[Nakama] Auto-joined match:", matched.match_id);
} catch (err) {
console.error("[Nakama] Failed to join match:", err);
}
};
}
// ----------------------------------------------------
// MATCHMAKING
// ----------------------------------------------------
async function joinMatchmaker(mode: string) {
if (!socket) throw new Error("socket missing");
console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`);
const ticket: MatchmakerTicket = await socket.addMatchmaker(
`*`, // query
2, // min count
2, // max count
{ mode } // stringProperties
);
return ticket.ticket;
}
// ----------------------------------------------------
// EXPLICIT MATCH JOIN
// ----------------------------------------------------
async function joinMatch(id: string) {
if (!socket) throw new Error("socket missing");
await socket.joinMatch(id);
setMatchId(id);
console.log("[Nakama] Joined match", id);
}
// ----------------------------------------------------
// MATCH STATE SEND
// ----------------------------------------------------
function sendMatchData(matchId: string, op: number, data: object) {
if (!socket) return;
socket.sendMatchState(matchId, op, JSON.stringify(data));
}
// ----------------------------------------------------
// MATCH DATA LISTENER
// ----------------------------------------------------
function onMatchData(cb: (msg: any) => void) {
if (!socket) return;
socket.onmatchdata = (m: MatchData) => {
const decoded = JSON.parse(new TextDecoder().decode(m.data));
cb({
opCode: m.op_code,
data: decoded,
userId: m.presence?.user_id ?? null,
});
};
}
return (
<NakamaContext.Provider
value={{
client,
session,
socket,
matchId,
loginOrRegister,
joinMatchmaker,
joinMatch,
sendMatchData,
onMatchData,
}}
>
{children}
</NakamaContext.Provider>
);
}
// ---------------------------------------------
// USE HOOK
// ---------------------------------------------
export function useNakama(): NakamaContextType {
const ctx = useContext(NakamaContext);
if (!ctx) throw new Error("useNakama must be inside a NakamaProvider");
return ctx;
}

View File

@@ -1,53 +0,0 @@
body {
font-family: sans-serif;
background: #f4f4f4;
display: flex;
justify-content: center;
padding-top: 50px;
margin: 0;
}
.game-container {
text-align: center;
}
.board {
width: 300px;
height: 300px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin: 20px auto;
}
.square {
background: white;
border: 2px solid #333;
font-size: 2rem;
font-weight: bold;
cursor: pointer;
height: 100px;
width: 100px;
}
.square:hover {
background: #eee;
}
.status {
font-size: 1.4rem;
margin-bottom: 10px;
}
.reset-btn {
padding: 10px 20px;
background: #333;
color: white;
border: none;
cursor: pointer;
font-size: 1rem;
}
.reset-btn:hover {
background: #555;
}

91
src/utils/haikus.ts Normal file
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];
}

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

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