A hex-based tactical board game with Godot frontend and FastAPI backend.
cd backend
pip install -e .
uvicorn app.main:app --reload- Open
godot/project.godotin Godot 4.3 - Run the project
This document is our working plan and starter scaffolds now that we will focus on Godot for the frontend and use minimal Cursor help for backend utilities.
trc/ README.md backend/ app/ init.py main.py # FastAPI factory + CORS api.py # Routers for /legal_moves, /apply_move, /resolve_combat, /ai_move, /save, /load models.py # Pydantic models (Hex, Unit, GameState, Move, CombatResolution, etc.) engine/ init.py rules.py # Pure functions for rules; deterministic map.py # Hex/zone of control logic, terrain rng.py # Deterministic RNG wrapper storage/ init.py memory.py # In-memory Store for dev tests/ test_rules.py test_api.py pyproject.toml uv.lock # or poetry.lock / requirements.txt (choose one) godot/ project.godot scenes/ Main.tscn Board.tscn scripts/ GameClient.gd # HTTP client, state sync HexGrid.gd # Axial grid math + drawing + picking Board.gd # Renders units, handles clicks, highlights legal moves assets/ images/ # temporary placeholders; VASSAL-sourced images later (private dev use) exports/ web/ # HTML5 export preset targeting iPad Safari docs/ api_contract.md rules_notes.md
See docs/api_contract.md for the complete API specification.
All endpoints return { "ok": true, "data": ... } on success and { "ok": false, "error": "..." } on failure.
Models // Hex { "q": 0, "r": 0 }
// Move { "from": {"q":0,"r":0}, "to": {"q":1,"r":0}, "unit_id": "G1" }
// GameState (minimal) { "id":"game_001", "turn":1, "side":"Axis", "units":[{"id":"G1","side":"Axis","q":0,"r":0}], "seed": 12345 } Endpoints
GET /health → { ok:true, data:{version:"0.1"} }
POST /new_game body: { "seed": 12345 } → { ok:true, data:GameState }
POST /legal_moves body: { "state": GameState, "unit_id": "G1" } → { ok:true, data:[Hex...] }
POST /apply_move body: { "state": GameState, "move": Move } → { ok:true, data:GameState }
POST /resolve_combat body: { "state": GameState, "combats": [...] } → { ok:true, data:GameState }
POST /ai_move body: { "state": GameState } → { ok:true, data:{ move: Move, state: GameState } }
POST /save body: { "state": GameState } → { ok:true }
POST /load body: { "id": "game_001" } → { ok:true, data: GameState }
See backend/README.md for setup instructions.
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .api import router
def create_app() -> FastAPI: app = FastAPI(title="TRC API", version="0.1") app.add_middleware( CORSMiddleware, allow_origins=[""], allow_credentials=True, allow_methods=[""], allow_headers=["*"], ) app.include_router(router) return app
app = create_app()
from fastapi import APIRouter from pydantic import BaseModel from typing import List, Optional, Literal
router = APIRouter()
class Hex(BaseModel): q: int r: int
class Unit(BaseModel): id: str side: Literal["Axis","Soviet"] q: int r: int
class GameState(BaseModel): id: str turn: int side: Literal["Axis","Soviet"] units: List[Unit] seed: int
class Move(BaseModel): unit_id: str from_: Hex to: Hex
@router.get("/health") def health(): return {"ok": True, "data": {"version": "0.1"}}
@router.post("/new_game") def new_game(seed: Optional[int] = 12345): state = { "id": "game_001", "turn": 1, "side": "Axis", "units": [{"id":"G1","side":"Axis","q":0,"r":0}], "seed": seed or 12345, } return {"ok": True, "data": state}
@router.post("/legal_moves") def legal_moves(payload: dict): # TODO: call engine.rules.legal_moves(...) return {"ok": True, "data": [{"q":1,"r":0},{"q":0,"r":1}]}
@router.post("/apply_move") def apply_move(payload: dict): # TODO: apply and return updated state return {"ok": True, "data": payload.get("state")}
from dataclasses import dataclass import random
@dataclass class DeterministicRNG: seed: int def post_init(self): self._rng = random.Random(self.seed) def roll(self, sides: int = 6) -> int: return self._rng.randint(1, sides) def choice(self, seq): return self._rng.choice(seq)
from .rng import DeterministicRNG
def legal_moves(state, unit_id): # Placeholder: return axial neighbors within map bounds return []
[project] name = "trc-backend" version = "0.1.0" dependencies = [ "fastapi", "uvicorn", "pydantic>=2", ] [tool.pytest.ini_options] pythonpath = ["app"]
See godot/README.md for setup instructions.
Scene tree
Main.tscn → root Node with a Board child and an HTTPRequest node
Board.tscn → Node2D with script Board.gd and a HexGrid script attached or instantiated
Axial math and drawing
extends Node2D
class_name HexGrid
const HEX_SIZE := 40.0
func axial_to_pixel(q: int, r: int) -> Vector2: var x = HEX_SIZE * (3.0/2.0 * q) var y = HEX_SIZE * (sqrt(3)/2.0 * q + sqrt(3) * r) return Vector2(x, y)
func hex_corners(center: Vector2) -> PackedVector2Array: var pts: PackedVector2Array = [] for i in 6: var angle = deg_to_rad(60 * i + 30) pts.append(center + Vector2(HEX_SIZE * cos(angle), HEX_SIZE * sin(angle))) return pts
func _draw(): # simple 10x10 grid for q in range(-5,5): for r in range(-5,5): var c = axial_to_pixel(q,r) draw_polyline(hex_corners(c), Color.WHITE, 2) Board input and highlighting
extends Node2D
@onready var grid: HexGrid = $HexGrid var legal: Array[Vector2i] = []
func set_legal_moves(axial_list): legal.clear() for h in axial_list: legal.append(Vector2i(h.q, h.r)) update()
func _draw(): grid._draw() for h in legal: var c = grid.axial_to_pixel(h.x, h.y) draw_circle(c, 8, Color(0,1,0,0.6)) HTTP client glue
extends Node
@onready var http: HTTPRequest = $HTTPRequest @onready var board = $Board var api_base := "http://127.0.0.1:8000"
func _ready(): new_game()
func new_game(): var url = api_base + "/new_game" http.request(url, [], HTTPClient.METHOD_POST)
func _on_HTTPRequest_request_completed(result, response_code, headers, body): var json = JSON.parse_string(body.get_string_from_utf8()) if json and json.ok: # Example: request legal moves for unit G1 var state = json.data var payload = JSON.stringify({"state": state, "unit_id": "G1"}) http.request(api_base+"/legal_moves", [], HTTPClient.METHOD_POST, payload) elif json and json.data and json.data is Array: board.set_legal_moves(json.data)
Wire the HTTPRequest node signal request_completed to _on_HTTPRequest_request_completed.
For iPad Safari deployment:
Godot 4.3 Web export preset
Canvas/WebGL: WebGL2
Threads: Off for iOS Safari
GDNative: not needed
Limit initial memory to a sensible size; test loading on iPad
Serve with proper Cross-Origin-Opener-Policy/Cross-Origin-Embedder-Policy disabled for single-thread build
See docs/rules_notes.md for detailed rules and notes.
Backend is source of truth for state transitions and RNG via DeterministicRNG(seed)
Include seed and a history array in state for debugging
All combat results come from backend; client only renders
Test suite covers rules and API endpoints:
test_rules.py: unit adjacency, ZOC basics, movement allowance
test_api.py: schema contracts and idempotent apply_move
Golden-state roundtrip: /new_game → /apply_move → /save → /load