Working first version. The web UI works well, can add players manually. We can see bots on there

This commit is contained in:
Isaac Johnson 2026-09-05 16:10:18 -05:00
commit 1a62477528
39 changed files with 4519 additions and 0 deletions

13
.dockerignore Normal file
View File

@ -0,0 +1,13 @@
.git
.gitignore
.env
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
node_modules
frontend/node_modules
frontend/dist
.DS_Store

328
.gitignore vendored Normal file
View File

@ -0,0 +1,328 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,node,linux
# Edit at https://www.toptal.com/developers/gitignore?templates=python,node,linux
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,node,linux

49
Dockerfile Normal file
View File

@ -0,0 +1,49 @@
# ==========================================
# Stage 1: Build Frontend (Vite + React + TS)
# ==========================================
FROM node:22-alpine AS frontend-builder
WORKDIR /build/frontend
# Install dependencies
COPY frontend/package*.json ./
RUN npm ci || npm install
# Build frontend production bundle
COPY frontend/ ./
RUN npm run build
# ==========================================
# Stage 2: Python Backend (FastAPI + Uvicorn)
# ==========================================
FROM python:3.12-slim AS production
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app/backend \
PORT=8000
# Install curl for container healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Install backend Python dependencies
COPY backend/requirements.txt ./backend/requirements.txt
RUN pip install --no-cache-dir -r ./backend/requirements.txt
# Copy backend source code
COPY backend ./backend
# Copy compiled frontend from builder stage
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
# Expose FastAPI application port
EXPOSE 8000
# Container healthcheck
HEALTHCHECK --interval=20s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/api/health || exit 1
# Start FastAPI server via Uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

112
README.md Normal file
View File

@ -0,0 +1,112 @@
# botWebWars
A FastAPI and React-based web arena featuring a real-time **64 × 64 grid** with coordinates ranging from `(0, 0)` to `(64, 64)`. Players register with their name and custom avatar color and are randomly placed onto the grid.
---
## Features
- **FastAPI Backend**:
- `POST /api/players` (or `/api/register`): Register a player with `name` and `color`. The game places them at a random unoccupied `(x, y)` coordinate between `(0, 0)` and `(64, 64)`.
- `GET /api/board`: Returns grid dimensions, player count, and player list.
- `GET /api/players`: Lists active players.
- `DELETE /api/players/{player_id}`: Remove a player from the arena.
- `POST /api/board/reset`: Reset the board and clear all players.
- `/ws`: Real-time WebSocket feed pushing live player spawns, departures, and board resets to all connected clients.
- Interactive OpenAPI/Swagger documentation at `/docs`.
- **React + Vite + Tailwind CSS Frontend**:
- High-performance HTML5 Canvas rendering for the 64×64 arena with pan (click & drag), zoom (mouse wheel or zoom buttons), and grid coordinate rulers.
- Hover cursor coordinate tracker showing exact `(x, y)` coordinates.
- Glowing bot avatars rendered in the player's chosen color with nameplates and pulsing selection rings.
- Live registration modal with interactive color picker, cyberpunk color presets, and real-time avatar preview.
- Sidebar showing all active bots and coordinates; click any bot to focus the camera on them.
- 1-click **Quick Spawn Bot** button for rapid testing.
- Real-time synchronization via WebSockets with auto-reconnection.
- **Dockerized Single-Container Deployment**:
- Multi-stage `Dockerfile` compiles the frontend with Node 22 and packages it into a lightweight Python 3.12 image.
- FastAPI serves both the API/WebSocket endpoints and the compiled SPA frontend.
---
## Quick Start with Docker
### Option 1: Docker Compose (Recommended)
```bash
docker compose up --build
```
Open [http://localhost:8000](http://localhost:8000) in your browser.
### Option 2: Docker CLI
```bash
# Build the Docker image
docker build -t botwebwars .
# Run the container
docker run -d -p 8000:8000 --name botwebwars botwebwars
```
- Webapp & Arena: [http://localhost:8000](http://localhost:8000)
- Swagger API Docs: [http://localhost:8000/docs](http://localhost:8000/docs)
---
## API Usage Examples
### Register a Player
```bash
curl -X POST http://localhost:8000/api/players \
-H "Content-Type: application/json" \
-d '{"name": "CyberViper", "color": "#38BDF8"}'
```
Response:
```json
{
"id": "bot_a1b2c3d4",
"name": "CyberViper",
"color": "#38BDF8",
"x": 42,
"y": 18,
"created_at": "2026-09-05T21:00:00.000000Z"
}
```
### Inspect Board State
```bash
curl http://localhost:8000/api/board
```
### Clear / Reset Board
```bash
curl -X POST http://localhost:8000/api/board/reset
```
---
## Local Development (Without Docker)
### Backend
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
The Vite development server runs at [http://localhost:5173](http://localhost:5173) and proxies `/api` and `/ws` to FastAPI on port 8000.
### Running Backend Tests
```bash
pytest backend/tests
```

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""botWebWars backend package."""

122
backend/app/api/routes.py Normal file
View File

@ -0,0 +1,122 @@
from typing import List
from fastapi import APIRouter, HTTPException, status
from app.api.websocket import manager
from app.game import game_engine
from app.models import ApiResponse, BoardState, Player, PlayerCreate
router = APIRouter()
@router.get("/health", response_model=ApiResponse, tags=["System"])
async def health_check():
return ApiResponse(
success=True,
message="botWebWars backend service is healthy",
data={"service": "FastAPI", "status": "running"},
)
@router.post(
"/players",
response_model=Player,
status_code=status.HTTP_201_CREATED,
summary="Register a new player on the board",
tags=["Players"],
)
@router.post(
"/register",
response_model=Player,
status_code=status.HTTP_201_CREATED,
include_in_schema=False,
)
async def register_player(player_in: PlayerCreate):
player = await game_engine.register_player(player_in)
# Broadcast to all connected WebSocket clients
await manager.broadcast({
"event": "player_joined",
"player": player.model_dump(),
"player_count": len(await game_engine.get_all_players()),
})
return player
@router.get(
"/players",
response_model=List[Player],
summary="List all registered players",
tags=["Players"],
)
async def list_players():
return await game_engine.get_all_players()
@router.get(
"/players/{player_id}",
response_model=Player,
summary="Get details of a specific player",
tags=["Players"],
)
async def get_player(player_id: str):
player = await game_engine.get_player(player_id)
if not player:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Player '{player_id}' not found",
)
return player
@router.delete(
"/players/{player_id}",
response_model=ApiResponse,
summary="Remove a player from the board",
tags=["Players"],
)
async def remove_player(player_id: str):
removed = await game_engine.remove_player(player_id)
if not removed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Player '{player_id}' not found",
)
await manager.broadcast({
"event": "player_left",
"player_id": player_id,
"player_count": len(await game_engine.get_all_players()),
})
return ApiResponse(
success=True,
message=f"Player '{player_id}' successfully removed",
)
@router.get(
"/board",
response_model=BoardState,
summary="Get full board configuration and player positions",
tags=["Board"],
)
async def get_board():
return await game_engine.get_board_state()
@router.post(
"/board/reset",
response_model=ApiResponse,
summary="Reset the board and clear all players",
tags=["Board"],
)
async def reset_board():
await game_engine.reset()
await manager.broadcast({
"event": "board_reset",
"player_count": 0,
})
return ApiResponse(
success=True,
message="Board reset successfully. All players cleared.",
)

View File

@ -0,0 +1,38 @@
import json
import logging
from typing import Set
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
self.active_connections: Set[WebSocket] = set()
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
logger.info(f"WebSocket client connected. Total clients: {len(self.active_connections)}")
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
logger.info(f"WebSocket client disconnected. Remaining clients: {len(self.active_connections)}")
async def broadcast(self, message: dict):
if not self.active_connections:
return
payload = json.dumps(message, default=str)
stale_connections = set()
for connection in self.active_connections:
try:
await connection.send_text(payload)
except Exception as e:
logger.warning(f"Failed to send to WebSocket client: {e}")
stale_connections.add(connection)
for stale in stale_connections:
self.active_connections.discard(stale)
manager = ConnectionManager()

30
backend/app/config.py Normal file
View File

@ -0,0 +1,30 @@
import os
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="BOTWEBWARS_")
APP_NAME: str = "botWebWars"
DEBUG: bool = False
# Board coordinates: 64x64 grid with coordinates from (0,0) to (64,64)
GRID_MIN_X: int = 0
GRID_MAX_X: int = 64
GRID_MIN_Y: int = 0
GRID_MAX_Y: int = 64
# Max players before rejection or wrapping
MAX_PLAYERS: int = 4096
# Static files directory for frontend
FRONTEND_DIR: Path = Path(
os.getenv(
"BOTWEBWARS_FRONTEND_DIR",
str(Path(__file__).resolve().parent.parent.parent / "frontend" / "dist")
)
)
settings = Settings()

99
backend/app/game.py Normal file
View File

@ -0,0 +1,99 @@
import asyncio
import random
import uuid
from typing import Dict, List, Optional, Tuple
from app.config import settings
from app.models import BoardConfig, BoardState, Player, PlayerCreate
class GameEngine:
def __init__(self):
self._lock = asyncio.Lock()
self.players: Dict[str, Player] = {}
self.config = BoardConfig(
min_x=settings.GRID_MIN_X,
max_x=settings.GRID_MAX_X,
min_y=settings.GRID_MIN_Y,
max_y=settings.GRID_MAX_Y,
grid_cells_x=settings.GRID_MAX_X - settings.GRID_MIN_X,
grid_cells_y=settings.GRID_MAX_Y - settings.GRID_MIN_Y,
)
def _get_occupied_coordinates(self) -> set[Tuple[int, int]]:
return {(p.x, p.y) for p in self.players.values()}
def _find_random_free_position(self) -> Tuple[int, int]:
occupied = self._get_occupied_coordinates()
total_possible = (self.config.max_x - self.config.min_x + 1) * (
self.config.max_y - self.config.min_y + 1
)
if len(occupied) >= total_possible:
# All spots taken, fallback to random any spot
return (
random.randint(self.config.min_x, self.config.max_x),
random.randint(self.config.min_y, self.config.max_y),
)
# Fast random sampling
for _ in range(100):
rx = random.randint(self.config.min_x, self.config.max_x)
ry = random.randint(self.config.min_y, self.config.max_y)
if (rx, ry) not in occupied:
return (rx, ry)
# Fallback exhaustive search if dense
all_coords = [
(x, y)
for x in range(self.config.min_x, self.config.max_x + 1)
for y in range(self.config.min_y, self.config.max_y + 1)
if (x, y) not in occupied
]
return random.choice(all_coords)
async def register_player(self, player_in: PlayerCreate) -> Player:
async with self._lock:
player_id = f"bot_{uuid.uuid4().hex[:8]}"
x, y = self._find_random_free_position()
player = Player(
id=player_id,
name=player_in.name,
color=player_in.color,
x=x,
y=y,
)
self.players[player_id] = player
return player
async def get_player(self, player_id: str) -> Optional[Player]:
async with self._lock:
return self.players.get(player_id)
async def get_all_players(self) -> List[Player]:
async with self._lock:
return list(self.players.values())
async def remove_player(self, player_id: str) -> bool:
async with self._lock:
if player_id in self.players:
del self.players[player_id]
return True
return False
async def reset(self) -> None:
async with self._lock:
self.players.clear()
async def get_board_state(self) -> BoardState:
async with self._lock:
players_list = list(self.players.values())
return BoardState(
config=self.config,
player_count=len(players_list),
players=players_list,
)
# Global game engine instance
game_engine = GameEngine()

95
backend/app/main.py Normal file
View File

@ -0,0 +1,95 @@
import json
import logging
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api.routes import router as api_router
from app.api.websocket import manager
from app.config import settings
from app.game import game_engine
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="botWebWars API",
description="Backend service for botWebWars 64x64 grid battle arena.",
version="1.0.0",
)
# Enable CORS for frontend development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Mount REST API routes
app.include_router(api_router, prefix="/api")
# WebSocket endpoint for real-time board updates
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await manager.connect(websocket)
try:
# Immediately transmit current board state on connection
state = await game_engine.get_board_state()
await websocket.send_text(
json.dumps({"event": "init", "state": state.model_dump()}, default=str)
)
while True:
# Keep connection open, receive ping or client commands
data = await websocket.receive_text()
try:
msg = json.loads(data)
if msg.get("action") == "ping":
await websocket.send_text(json.dumps({"event": "pong"}))
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
manager.disconnect(websocket)
except Exception as e:
logger.error(f"WebSocket error: {e}")
manager.disconnect(websocket)
# Mount static frontend files if built
frontend_dist = settings.FRONTEND_DIR
if frontend_dist.exists() and (frontend_dist / "index.html").exists():
logger.info(f"Serving frontend static files from: {frontend_dist}")
# Mount assets folder
assets_dir = frontend_dist / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
# SPA catch-all for root and unmatched GET paths (excluding /api and /ws)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
# If the requested file directly exists inside frontend_dist, serve it
target_file = frontend_dist / full_path
if full_path and target_file.is_file():
return FileResponse(target_file)
# Otherwise fallback to index.html for SPA routing
return FileResponse(frontend_dist / "index.html")
else:
logger.info("Frontend dist directory not found. Running in API-only mode.")
@app.get("/")
async def root():
return {
"message": "Welcome to botWebWars API! Frontend build not detected yet.",
"docs": "/docs",
"health": "/api/health",
"board": "/api/board",
}

58
backend/app/models.py Normal file
View File

@ -0,0 +1,58 @@
import re
from datetime import datetime, timezone
from typing import List, Optional
from pydantic import BaseModel, Field, field_validator
class PlayerCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
cleaned = v.strip()
if not cleaned:
raise ValueError("Player name cannot be empty or only whitespace")
return cleaned
@field_validator("color")
@classmethod
def validate_color(cls, v: str) -> str:
cleaned = v.strip()
# Accept #RRGGBB, #RGB, or standard alphanumeric color names
if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", cleaned):
return cleaned.upper()
if re.match(r"^[a-zA-Z]{3,20}$", cleaned):
return cleaned.lower()
raise ValueError("Color must be a valid hex code (e.g. #FF4444) or color name (e.g. crimson)")
class Player(BaseModel):
id: str
name: str
color: str
x: int
y: int
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class BoardConfig(BaseModel):
min_x: int = 0
max_x: int = 64
min_y: int = 0
max_y: int = 64
grid_cells_x: int = 64
grid_cells_y: int = 64
class BoardState(BaseModel):
config: BoardConfig
player_count: int
players: List[Player]
class ApiResponse(BaseModel):
success: bool
message: str
data: Optional[dict] = None

8
backend/requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi>=0.110.0
uvicorn[standard]>=0.28.0
pydantic>=2.6.0
pydantic-settings>=2.2.0
websockets>=12.0
httpx>=0.27.0
pytest>=8.0.0
pytest-asyncio>=0.23.0

82
backend/tests/test_api.py Normal file
View File

@ -0,0 +1,82 @@
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.game import game_engine
@pytest.fixture(autouse=True)
def reset_board_state():
import asyncio
asyncio.run(game_engine.reset())
yield
asyncio.run(game_engine.reset())
def test_health():
client = TestClient(app)
response = client.get("/api/health")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "FastAPI" in data["message"] or data["data"]["service"] == "FastAPI"
def test_register_player():
client = TestClient(app)
res = client.post("/api/players", json={"name": "CyberBot", "color": "#00FFAA"})
assert res.status_code == 201
player = res.json()
assert player["name"] == "CyberBot"
assert player["color"] == "#00FFAA"
assert 0 <= player["x"] <= 64
assert 0 <= player["y"] <= 64
assert "id" in player
def test_register_alias_endpoint():
client = TestClient(app)
res = client.post("/api/register", json={"name": "AliasBot", "color": "#FF00FF"})
assert res.status_code == 201
player = res.json()
assert player["name"] == "AliasBot"
assert 0 <= player["x"] <= 64
assert 0 <= player["y"] <= 64
def test_board_state_after_registrations():
client = TestClient(app)
client.post("/api/players", json={"name": "Bot1", "color": "#FF0000"})
client.post("/api/players", json={"name": "Bot2", "color": "#0000FF"})
res = client.get("/api/board")
assert res.status_code == 200
board = res.json()
assert board["player_count"] == 2
assert len(board["players"]) == 2
assert board["config"]["min_x"] == 0
assert board["config"]["max_x"] == 64
assert board["config"]["min_y"] == 0
assert board["config"]["max_y"] == 64
def test_player_deletion_and_reset():
client = TestClient(app)
reg_res = client.post("/api/players", json={"name": "TempBot", "color": "#123456"})
player_id = reg_res.json()["id"]
del_res = client.delete(f"/api/players/{player_id}")
assert del_res.status_code == 200
board_res = client.get("/api/board")
assert board_res.json()["player_count"] == 0
def test_validation_errors():
client = TestClient(app)
# Empty name
res = client.post("/api/players", json={"name": " ", "color": "#FF0000"})
assert res.status_code == 422
# Invalid color
res = client.post("/api/players", json={"name": "Bot", "color": "not-a-color-123456789"})
assert res.status_code == 422

11
docker-compose.yml Normal file
View File

@ -0,0 +1,11 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: botwebwars
ports:
- "8000:8000"
environment:
- BOTWEBWARS_DEBUG=false
restart: unless-stopped

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
frontend/README.md Normal file
View File

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2338bdf8'><path d='M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2zM7.5 13a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3zm9 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>botWebWars • 64x64 Grid Arena</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1874
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "botwebwars-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^1.16.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"tailwindcss": "^4.1.18",
"typescript": "~6.0.2",
"vite": "^8.2.2"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

131
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,131 @@
import { useState } from 'react';
import { useGameSocket } from './hooks/useGameSocket';
import { BoardCanvas } from './components/BoardCanvas';
import { Header } from './components/Header';
import { PlayerList } from './components/PlayerList';
import { RegisterModal } from './components/RegisterModal';
const BOT_NAMES = [
'ViperStrike',
'PulseMatrix',
'NexusGlitch',
'AegisPrime',
'CobaltDrift',
'PhantomCore',
'ZephyrByte',
'TitanSpark',
'ShadowGrid',
'QuantumFang',
];
const BOT_COLORS = [
'#38BDF8', // Sky
'#F43F5E', // Rose
'#10B981', // Emerald
'#F59E0B', // Amber
'#A855F7', // Purple
'#EC4899', // Pink
'#06B6D4', // Cyan
'#6366F1', // Indigo
];
export function App() {
const {
boardState,
isConnected,
selectedPlayer,
setSelectedPlayer,
registerPlayer,
removePlayer,
resetBoard,
} = useGameSocket();
const [isRegisterOpen, setIsRegisterOpen] = useState(false);
const [notification, setNotification] = useState<string | null>(null);
const showNotification = (msg: string) => {
setNotification(msg);
setTimeout(() => {
setNotification((curr) => (curr === msg ? null : curr));
}, 3500);
};
const handleQuickSpawn = async () => {
const name = BOT_NAMES[Math.floor(Math.random() * BOT_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100);
const color = BOT_COLORS[Math.floor(Math.random() * BOT_COLORS.length)];
try {
const player = await registerPlayer(name, color);
showNotification(`Spawned ${player.name} at (${player.x}, ${player.y})`);
} catch (err: unknown) {
if (err instanceof Error) {
showNotification(`Error: ${err.message}`);
} else {
showNotification('Failed to spawn bot');
}
}
};
const handleReset = async () => {
if (window.confirm('Are you sure you want to clear all bots from the board?')) {
try {
await resetBoard();
showNotification('Board cleared');
} catch (err: unknown) {
if (err instanceof Error) {
showNotification(`Error: ${err.message}`);
}
}
}
};
return (
<div className="flex flex-col h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
{/* Top Navigation Bar */}
<Header
isConnected={isConnected}
onOpenRegister={() => setIsRegisterOpen(true)}
onQuickSpawn={handleQuickSpawn}
onResetBoard={handleReset}
playerCount={boardState.player_count}
/>
{/* Main Content Area */}
<div className="flex-1 flex overflow-hidden relative">
{/* Interactive Grid Canvas Viewport */}
<BoardCanvas
boardState={boardState}
selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer}
/>
{/* Sidebar Player Roster */}
<PlayerList
players={boardState.players}
selectedPlayer={selectedPlayer}
onSelectPlayer={setSelectedPlayer}
onRemovePlayer={removePlayer}
/>
{/* Toast Notification */}
{notification && (
<div className="absolute bottom-6 right-84 z-50 bg-slate-900 border border-sky-500/50 text-sky-200 px-4 py-2.5 rounded-xl shadow-2xl backdrop-blur-md text-xs font-mono flex items-center gap-2 animate-bounce">
<span className="w-2 h-2 rounded-full bg-sky-400" />
{notification}
</div>
)}
</div>
{/* Register Player Modal */}
<RegisterModal
isOpen={isRegisterOpen}
onClose={() => setIsRegisterOpen(false)}
onRegister={async (name, color) => {
const player = await registerPlayer(name, color);
showNotification(`Deployed ${player.name} at coordinate (${player.x}, ${player.y})!`);
}}
/>
</div>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,434 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import type { BoardState, Player } from '../types';
interface BoardCanvasProps {
boardState: BoardState;
selectedPlayer: Player | null;
onSelectPlayer: (player: Player | null) => void;
onHoverCoord?: (coord: { x: number; y: number } | null) => void;
}
export const BoardCanvas: React.FC<BoardCanvasProps> = ({
boardState,
selectedPlayer,
onSelectPlayer,
onHoverCoord,
}) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
// Viewport transforms
const [zoom, setZoom] = useState<number>(1);
const [offset, setOffset] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState<boolean>(false);
const [dragStart, setDragStart] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [hoveredCoord, setHoveredCoord] = useState<{ x: number; y: number } | null>(null);
const [hoveredPlayer, setHoveredPlayer] = useState<Player | null>(null);
const { min_x, max_x, min_y, max_y } = boardState.config;
const gridCellsX = max_x - min_x;
const gridCellsY = max_y - min_y;
// Convert canvas pixel coordinates to grid coordinate (0..64)
const pixelToGrid = useCallback(
(px: number, py: number, width: number, height: number) => {
const padding = 45;
const arenaSize = Math.min(width - padding * 2, height - padding * 2);
const startX = (width - arenaSize) / 2 + offset.x;
const startY = (height - arenaSize) / 2 + offset.y;
const cellSize = (arenaSize / gridCellsX) * zoom;
const gx = Math.round((px - startX) / cellSize);
const gy = Math.round((py - startY) / cellSize);
if (gx >= min_x && gx <= max_x && gy >= min_y && gy <= max_y) {
return { x: gx, y: gy };
}
return null;
},
[offset, zoom, gridCellsX, min_x, max_x, min_y, max_y]
);
// Reset viewport to fit
const handleResetView = useCallback(() => {
setZoom(1);
setOffset({ x: 0, y: 0 });
}, []);
// Center camera on selected player
useEffect(() => {
if (selectedPlayer && containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
const padding = 45;
const arenaSize = Math.min(width - padding * 2, height - padding * 2);
const cellSize = (arenaSize / gridCellsX) * zoom;
const targetPx = (selectedPlayer.x - min_x) * cellSize;
const targetPy = (selectedPlayer.y - min_y) * cellSize;
setOffset({
x: -(targetPx - arenaSize / 2),
y: -(targetPy - arenaSize / 2),
});
}
}, [selectedPlayer?.id, min_x, min_y, gridCellsX, zoom]);
// Main render loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let animationFrameId: number;
const render = () => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
canvas.width = width * dpr;
canvas.height = height * dpr;
}
ctx.save();
ctx.scale(dpr, dpr);
// Deep sci-fi cyberpunk background
const bgGrad = ctx.createLinearGradient(0, 0, width, height);
bgGrad.addColorStop(0, '#090d16');
bgGrad.addColorStop(1, '#05070c');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
// Arena boundary calculation
const padding = 45;
const arenaSize = Math.min(width - padding * 2, height - padding * 2);
const startX = (width - arenaSize) / 2 + offset.x;
const startY = (height - arenaSize) / 2 + offset.y;
const cellSize = (arenaSize / gridCellsX) * zoom;
const totalWidth = cellSize * gridCellsX;
const totalHeight = cellSize * gridCellsY;
// Draw Arena Background
ctx.fillStyle = '#0f172a';
ctx.fillRect(startX, startY, totalWidth, totalHeight);
// Clip grid area for internal drawing
ctx.save();
ctx.beginPath();
ctx.rect(startX, startY, totalWidth, totalHeight);
ctx.clip();
// Subtle checkered or minor grid lines
ctx.lineWidth = 0.5;
ctx.strokeStyle = '#1e293b';
for (let i = 0; i <= gridCellsX; i++) {
const x = startX + i * cellSize;
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + totalHeight);
ctx.stroke();
}
for (let j = 0; j <= gridCellsY; j++) {
const y = startY + j * cellSize;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(startX + totalWidth, y);
ctx.stroke();
}
// Major grid lines every 8 cells
ctx.lineWidth = 1.2;
ctx.strokeStyle = '#334155';
for (let i = 0; i <= gridCellsX; i += 8) {
const x = startX + i * cellSize;
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, startY + totalHeight);
ctx.stroke();
}
for (let j = 0; j <= gridCellsY; j += 8) {
const y = startY + j * cellSize;
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(startX + totalWidth, y);
ctx.stroke();
}
// Highlight hovered cell
if (hoveredCoord) {
const hx = startX + (hoveredCoord.x - min_x) * cellSize - cellSize / 2;
const hy = startY + (hoveredCoord.y - min_y) * cellSize - cellSize / 2;
ctx.fillStyle = 'rgba(56, 189, 248, 0.15)';
ctx.fillRect(hx, hy, cellSize, cellSize);
ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)';
ctx.lineWidth = 1;
ctx.strokeRect(hx, hy, cellSize, cellSize);
}
// Draw active players
const now = Date.now() / 400;
const pulse = Math.sin(now) * 0.2 + 0.8;
boardState.players.forEach((player) => {
const px = startX + (player.x - min_x) * cellSize;
const py = startY + (player.y - min_y) * cellSize;
const isSelected = selectedPlayer?.id === player.id;
const isHovered = hoveredPlayer?.id === player.id;
const baseRadius = Math.max(4, Math.min(cellSize * 0.45, 14));
const radius = isSelected ? baseRadius * 1.35 : isHovered ? baseRadius * 1.2 : baseRadius;
// Glow effect
ctx.save();
ctx.shadowColor = player.color;
ctx.shadowBlur = isSelected ? 18 : 8;
// Outer beacon ring
ctx.beginPath();
ctx.arc(px, py, radius * (isSelected ? 1.5 * pulse : 1.25), 0, Math.PI * 2);
ctx.strokeStyle = player.color;
ctx.globalAlpha = isSelected ? 0.9 : 0.4;
ctx.lineWidth = 1.5;
ctx.stroke();
// Main Player Token
ctx.globalAlpha = 1.0;
ctx.beginPath();
ctx.arc(px, py, radius, 0, Math.PI * 2);
ctx.fillStyle = player.color;
ctx.fill();
// Inner Core
ctx.beginPath();
ctx.arc(px, py, radius * 0.4, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
// Label above player if zoomed in or selected/hovered
if (zoom > 1.8 || isSelected || isHovered) {
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'center';
const text = player.name;
const textMetrics = ctx.measureText(text);
const bgWidth = textMetrics.width + 10;
const bgHeight = 16;
const labelY = py - radius - 8;
ctx.fillStyle = 'rgba(15, 23, 42, 0.85)';
ctx.strokeStyle = player.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#f8fafc';
ctx.fillText(text, px, labelY);
ctx.restore();
}
});
ctx.restore(); // end clip
// Draw Board Border
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 2;
ctx.strokeRect(startX, startY, totalWidth, totalHeight);
// Draw Coordinate Axis Rulers (0, 8, 16, 24, 32, 40, 48, 56, 64)
ctx.font = '10px monospace';
ctx.fillStyle = '#94a3b8';
ctx.textAlign = 'center';
for (let i = 0; i <= gridCellsX; i += 8) {
const x = startX + i * cellSize;
if (x >= startX - 5 && x <= startX + totalWidth + 5) {
ctx.fillText(`${i}`, x, startY - 8);
ctx.beginPath();
ctx.moveTo(x, startY - 4);
ctx.lineTo(x, startY);
ctx.strokeStyle = '#475569';
ctx.stroke();
}
}
ctx.textAlign = 'right';
for (let j = 0; j <= gridCellsY; j += 8) {
const y = startY + j * cellSize;
if (y >= startY - 5 && y <= startY + totalHeight + 5) {
ctx.fillText(`${j}`, startX - 8, y + 3);
ctx.beginPath();
ctx.moveTo(startX - 4, y);
ctx.lineTo(startX, y);
ctx.strokeStyle = '#475569';
ctx.stroke();
}
}
ctx.restore();
animationFrameId = requestAnimationFrame(render);
};
animationFrameId = requestAnimationFrame(render);
return () => cancelAnimationFrame(animationFrameId);
}, [boardState, selectedPlayer, hoveredPlayer, hoveredCoord, offset, zoom, gridCellsX, gridCellsY, min_x, max_x, min_y, max_y]);
// Mouse Drag to Pan
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button === 0) {
setIsDragging(true);
setDragStart({ x: e.clientX - offset.x, y: e.clientY - offset.y });
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
if (isDragging) {
setOffset({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y,
});
}
const coord = pixelToGrid(mouseX, mouseY, rect.width, rect.height);
setHoveredCoord(coord);
onHoverCoord?.(coord);
if (coord) {
const match = boardState.players.find((p) => p.x === coord.x && p.y === coord.y);
setHoveredPlayer(match || null);
} else {
setHoveredPlayer(null);
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
// Wheel to Zoom
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.88;
setZoom((prev) => Math.min(Math.max(prev * zoomFactor, 0.5), 8.0));
};
// Click to Select Player or Coordinate
const handleClick = () => {
if (hoveredPlayer) {
onSelectPlayer(hoveredPlayer);
} else if (hoveredCoord) {
const found = boardState.players.find((p) => p.x === hoveredCoord.x && p.y === hoveredCoord.y);
onSelectPlayer(found || null);
}
};
return (
<div
ref={containerRef}
className="relative w-full h-full flex-1 overflow-hidden bg-slate-950 select-none cursor-crosshair"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setIsDragging(false);
setHoveredCoord(null);
setHoveredPlayer(null);
}}
onWheel={handleWheel}
onClick={handleClick}
>
<canvas ref={canvasRef} className="w-full h-full block" />
{/* Floating Coordinate Display */}
<div className="absolute top-4 left-4 bg-slate-900/85 backdrop-blur border border-slate-700/60 rounded-md px-3 py-1.5 text-xs font-mono text-slate-300 flex items-center gap-3 shadow-lg pointer-events-none">
<span className="flex items-center gap-1.5 text-sky-400">
<span className="w-2 h-2 rounded-full bg-sky-400 animate-pulse" />
Grid: 64x64
</span>
<span className="text-slate-500">|</span>
<span>
Cursor:{' '}
<strong className="text-emerald-400">
{hoveredCoord ? `(${hoveredCoord.x}, ${hoveredCoord.y})` : '-- , --'}
</strong>
</span>
<span className="text-slate-500">|</span>
<span>
Zoom: <strong className="text-amber-400">{(zoom * 100).toFixed(0)}%</strong>
</span>
</div>
{/* Floating Reset View Button */}
<div className="absolute top-4 right-4 flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setZoom((z) => Math.min(z * 1.25, 8.0));
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 p-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Zoom In"
>
+
</button>
<button
onClick={(e) => {
e.stopPropagation();
setZoom((z) => Math.max(z * 0.8, 0.5));
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 p-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Zoom Out"
>
-
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleResetView();
}}
className="bg-slate-900/80 hover:bg-slate-800 text-slate-200 border border-slate-700 px-3 py-2 rounded-lg text-xs font-mono backdrop-blur transition-all shadow"
title="Reset View"
>
Reset View
</button>
</div>
{/* Selected Player Overlay card */}
{selectedPlayer && (
<div className="absolute bottom-4 left-4 bg-slate-900/90 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-xl flex items-center gap-3 text-xs max-w-sm">
<div
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white shadow-md border-2 border-white/30"
style={{ backgroundColor: selectedPlayer.color }}
>
{selectedPlayer.name.slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-slate-100 truncate">{selectedPlayer.name}</div>
<div className="text-slate-400 font-mono text-[11px]">
ID: {selectedPlayer.id} Pos: ({selectedPlayer.x}, {selectedPlayer.y})
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onSelectPlayer(null);
}}
className="text-slate-400 hover:text-slate-200 p-1"
>
</button>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,89 @@
import React from 'react';
interface HeaderProps {
isConnected: boolean;
onOpenRegister: () => void;
onQuickSpawn: () => void;
onResetBoard: () => void;
playerCount: number;
}
export const Header: React.FC<HeaderProps> = ({
isConnected,
onOpenRegister,
onQuickSpawn,
onResetBoard,
playerCount,
}) => {
return (
<header className="h-16 border-b border-slate-800 bg-slate-900/90 backdrop-blur px-6 flex items-center justify-between z-10">
{/* Brand & Stats */}
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-tr from-sky-500 to-indigo-600 flex items-center justify-center font-black text-white text-base shadow-lg shadow-sky-500/20">
W
</div>
<div>
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
botWebWars
<span className="text-[10px] uppercase font-mono px-1.5 py-0.5 rounded bg-sky-950 text-sky-400 border border-sky-800">
v1.0
</span>
</h1>
<p className="text-[11px] text-slate-400 font-mono">
64×64 Grid Arena Coords (0,0) to (64,64)
</p>
</div>
</div>
{/* Live status badge */}
<div className="flex items-center gap-2 px-2.5 py-1 rounded-full bg-slate-800 border border-slate-700 text-xs font-mono">
<span
className={`w-2 h-2 rounded-full ${
isConnected ? 'bg-emerald-400 shadow-[0_0_8px_#34d399]' : 'bg-amber-400 animate-ping'
}`}
/>
<span className={isConnected ? 'text-slate-300' : 'text-amber-300'}>
{isConnected ? 'LIVE SYNC' : 'CONNECTING...'}
</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3">
<a
href="/docs"
target="_blank"
rel="noopener noreferrer"
className="text-xs font-mono text-slate-400 hover:text-sky-400 transition-colors px-2.5 py-1.5 rounded-lg border border-slate-800 hover:border-slate-700 bg-slate-950/40"
title="Open FastAPI Swagger API Docs"
>
API Docs
</a>
{playerCount > 0 && (
<button
onClick={onResetBoard}
className="text-xs font-medium text-slate-400 hover:text-red-400 transition-colors px-3 py-1.5 rounded-lg border border-slate-800 hover:border-red-900/60 bg-slate-950/40"
>
Clear Board
</button>
)}
<button
onClick={onQuickSpawn}
className="text-xs font-medium text-slate-200 hover:text-white transition-all px-3.5 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 shadow flex items-center gap-1.5"
>
<span>🎲</span> Quick Spawn Bot
</button>
<button
onClick={onOpenRegister}
className="text-xs font-semibold text-white transition-all px-4 py-1.5 rounded-lg bg-gradient-to-r from-sky-500 to-indigo-600 hover:from-sky-400 hover:to-indigo-500 shadow-lg shadow-sky-500/25 flex items-center gap-1.5"
>
<span>+</span> Register Player
</button>
</div>
</header>
);
};

View File

@ -0,0 +1,97 @@
import React from 'react';
import type { Player } from '../types';
interface PlayerListProps {
players: Player[];
selectedPlayer: Player | null;
onSelectPlayer: (player: Player) => void;
onRemovePlayer: (id: string) => void;
}
export const PlayerList: React.FC<PlayerListProps> = ({
players,
selectedPlayer,
onSelectPlayer,
onRemovePlayer,
}) => {
return (
<div className="flex flex-col h-full bg-slate-900 border-l border-slate-800 w-80">
{/* Header */}
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-sky-400" />
<h3 className="font-semibold text-slate-100 text-sm">Active Bots</h3>
</div>
<span className="text-xs font-mono px-2 py-0.5 rounded-full bg-slate-800 text-slate-300 border border-slate-700">
{players.length} / 4096
</span>
</div>
{/* Players List */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{players.length === 0 ? (
<div className="text-center py-12 px-4 text-slate-500 text-xs">
<div className="text-2xl mb-2">🤖</div>
No players registered yet.
<br />
Register a player or spawn a random bot to place them on the grid!
</div>
) : (
players.map((player) => {
const isSelected = selectedPlayer?.id === player.id;
return (
<div
key={player.id}
onClick={() => onSelectPlayer(player)}
className={`group flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer ${
isSelected
? 'bg-sky-950/40 border-sky-500 shadow-md shadow-sky-500/10'
: 'bg-slate-950/60 border-slate-800 hover:border-slate-700 hover:bg-slate-800/40'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
style={{
backgroundColor: player.color,
boxShadow: `0 0 10px ${player.color}55`,
}}
>
{player.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="text-xs font-semibold text-slate-200 truncate">
{player.name}
</div>
<div className="text-[11px] font-mono text-slate-400">
pos: <span className="text-emerald-400">({player.x}, {player.y})</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 opacity-60 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onRemovePlayer(player.id);
}}
title="Remove Bot"
className="p-1 rounded text-slate-500 hover:text-red-400 hover:bg-red-950/40 transition-colors text-xs"
>
</button>
</div>
</div>
);
})
)}
</div>
{/* Footer Info */}
<div className="p-3 border-t border-slate-800 bg-slate-950/40 text-[11px] font-mono text-slate-400 flex justify-between items-center">
<span>Coordinate range:</span>
<span className="text-sky-400 font-semibold">[0,0] [64,64]</span>
</div>
</div>
);
};

View File

@ -0,0 +1,206 @@
import React, { useState } from 'react';
const PRESET_COLORS = [
'#38BDF8', // Sky Blue
'#F43F5E', // Rose / Red
'#10B981', // Emerald Green
'#F59E0B', // Amber
'#A855F7', // Purple
'#EC4899', // Pink
'#06B6D4', // Cyan
'#EAB308', // Yellow
'#6366F1', // Indigo
'#14B8A6', // Teal
];
const RANDOM_NAMES = [
'CyberViper',
'NexusBot',
'PulseGhost',
'ApexVector',
'IronShard',
'NovaMatrix',
'QuantumGlitch',
'EchoZero',
'TitanByte',
'ShadowCircuit',
];
interface RegisterModalProps {
isOpen: boolean;
onClose: () => void;
onRegister: (name: string, color: string) => Promise<unknown>;
}
export const RegisterModal: React.FC<RegisterModalProps> = ({
isOpen,
onClose,
onRegister,
}) => {
const [name, setName] = useState('');
const [color, setColor] = useState('#38BDF8');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!isOpen) return null;
const handleRandomize = () => {
const randomName = RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100);
const randomColor = PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)];
setName(randomName);
setColor(randomColor);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) {
setError('Please provide a bot name');
return;
}
try {
setIsSubmitting(true);
setError(null);
await onRegister(name.trim(), color);
onClose();
setName('');
} catch (err: unknown) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Failed to register player');
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-md p-6 shadow-2xl animate-in fade-in zoom-in duration-150">
<div className="flex justify-between items-center mb-5 pb-3 border-b border-slate-800">
<div>
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-emerald-400 inline-block animate-pulse" />
Register Player Avatar
</h2>
<p className="text-xs text-slate-400 mt-0.5">
Enter arena coordinates between (0,0) and (64,64)
</p>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-200 transition-colors p-1"
>
</button>
</div>
{error && (
<div className="mb-4 p-2.5 bg-red-950/60 border border-red-800 rounded-lg text-red-300 text-xs">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Avatar Preview */}
<div className="flex items-center justify-center py-4 bg-slate-950/60 rounded-xl border border-slate-800">
<div className="flex flex-col items-center gap-2">
<div
className="w-16 h-16 rounded-full flex items-center justify-center shadow-lg transition-transform duration-300 transform hover:scale-105"
style={{
backgroundColor: color,
boxShadow: `0 0 20px ${color}66`,
}}
>
<div className="w-6 h-6 rounded-full bg-white/90 shadow-inner flex items-center justify-center">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
</div>
</div>
<span className="text-xs font-mono font-medium text-slate-300">
{name.trim() ? name : 'Avatar Preview'}
</span>
</div>
</div>
{/* Player Name */}
<div>
<div className="flex justify-between items-center mb-1.5">
<label className="text-xs font-semibold text-slate-300">Player Name</label>
<button
type="button"
onClick={handleRandomize}
className="text-[11px] text-sky-400 hover:text-sky-300 underline underline-offset-2"
>
🎲 Randomize
</button>
</div>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. CyberKnight"
maxLength={32}
className="w-full bg-slate-950 border border-slate-700 focus:border-sky-500 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors"
required
/>
</div>
{/* Color Chooser */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-2">
Avatar Color
</label>
<div className="grid grid-cols-5 gap-2 mb-3">
{PRESET_COLORS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setColor(preset)}
className={`h-8 rounded-lg transition-transform ${
color.toUpperCase() === preset.toUpperCase()
? 'ring-2 ring-white scale-105'
: 'hover:scale-102 opacity-80 hover:opacity-100'
}`}
style={{ backgroundColor: preset }}
/>
))}
</div>
<div className="flex items-center gap-3">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-9 h-9 rounded cursor-pointer bg-transparent border-0 p-0"
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 bg-slate-950 border border-slate-700 focus:border-sky-500 rounded-lg px-3 py-1.5 text-xs font-mono text-slate-100 uppercase focus:outline-none"
/>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-slate-800">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-5 py-2 text-xs font-semibold text-white bg-sky-600 hover:bg-sky-500 disabled:opacity-50 rounded-lg transition-colors shadow-lg shadow-sky-600/30 flex items-center gap-2"
>
{isSubmitting ? 'Spawning...' : 'Deploy to Grid'}
</button>
</div>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,175 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type { BoardState, Player } from '../types';
const INITIAL_BOARD: BoardState = {
config: {
min_x: 0,
max_x: 64,
min_y: 0,
max_y: 64,
grid_cells_x: 64,
grid_cells_y: 64,
},
player_count: 0,
players: [],
};
export function useGameSocket() {
const [boardState, setBoardState] = useState<BoardState>(INITIAL_BOARD);
const [isConnected, setIsConnected] = useState(false);
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const fetchBoard = useCallback(async () => {
try {
const res = await fetch('/api/board');
if (res.ok) {
const data: BoardState = await res.json();
setBoardState(data);
}
} catch (err) {
console.error('Failed to fetch board state:', err);
}
}, []);
const connectWebSocket = useCallback(() => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return;
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
const wsUrl = `${protocol}//${host}/ws`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
console.log('Connected to botWebWars WebSocket');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.event === 'init') {
setBoardState(data.state);
} else if (data.event === 'player_joined') {
setBoardState((prev) => {
const exists = prev.players.some((p) => p.id === data.player.id);
const nextPlayers = exists
? prev.players.map((p) => (p.id === data.player.id ? data.player : p))
: [...prev.players, data.player];
return {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
};
});
} else if (data.event === 'player_left') {
setBoardState((prev) => {
const nextPlayers = prev.players.filter((p) => p.id !== data.player_id);
return {
...prev,
player_count: data.player_count ?? nextPlayers.length,
players: nextPlayers,
};
});
setSelectedPlayer((prev) => (prev?.id === data.player_id ? null : prev));
} else if (data.event === 'board_reset') {
setBoardState((prev) => ({
...prev,
player_count: 0,
players: [],
}));
setSelectedPlayer(null);
}
} catch (err) {
console.error('Error parsing WebSocket message:', err);
}
};
ws.onclose = () => {
setIsConnected(false);
reconnectTimeoutRef.current = window.setTimeout(() => {
connectWebSocket();
}, 2500);
};
ws.onerror = (err) => {
console.error('WebSocket encountered an error:', err);
ws.close();
};
}, []);
useEffect(() => {
fetchBoard();
connectWebSocket();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [fetchBoard, connectWebSocket]);
// Keep player selection in sync if player updates
useEffect(() => {
if (selectedPlayer) {
const updated = boardState.players.find((p) => p.id === selectedPlayer.id);
if (updated) {
setSelectedPlayer(updated);
} else {
setSelectedPlayer(null);
}
}
}, [boardState.players, selectedPlayer]);
const registerPlayer = async (name: string, color: string): Promise<Player> => {
const res = await fetch('/api/players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.detail || 'Failed to register player');
}
const newPlayer: Player = await res.json();
setSelectedPlayer(newPlayer);
return newPlayer;
};
const removePlayer = async (id: string) => {
const res = await fetch(`/api/players/${id}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error('Failed to remove player');
}
};
const resetBoard = async () => {
const res = await fetch('/api/board/reset', {
method: 'POST',
});
if (!res.ok) {
throw new Error('Failed to reset board');
}
};
return {
boardState,
isConnected,
selectedPlayer,
setSelectedPlayer,
registerPlayer,
removePlayer,
resetBoard,
refresh: fetchBoard,
};
}

33
frontend/src/index.css Normal file
View File

@ -0,0 +1,33 @@
@import "tailwindcss";
html,
body,
#root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
background-color: #020617;
color: #f8fafc;
overflow: hidden;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #0f172a;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

28
frontend/src/types.ts Normal file
View File

@ -0,0 +1,28 @@
export interface Player {
id: string;
name: string;
color: string;
x: number;
y: number;
created_at: string;
}
export interface BoardConfig {
min_x: number;
max_x: number;
min_y: number;
max_y: number;
grid_cells_x: number;
grid_cells_y: number;
}
export interface BoardState {
config: BoardConfig;
player_count: number;
players: Player[];
}
export interface PlayerCreateRequest {
name: string;
color: string;
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

24
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://127.0.0.1:8000',
ws: true,
}
}
}
})