eval-arena is a browser-based, turn-based strategy game built on SvelteKit 5. Players assemble squads of units and fight tactical battles on a grid-based tile map.
The following sections dive deep into the rules, systems, and architecture that make each match in eval‑arena function. Reading this gives you enough context to implement new units, tweak combat formulas, or extend the LLM prompts.
- Roster: There are currently a dozen unit types, each belonging to an archetype:
- Melee: Heavy infantry with high health and close‑range attacks.
- Ranged: Archers and artillery that trade mobility for distance.
- Support: Healers or buffers that can restore allies or debuff enemies.
- Special: Unique units with one‑off mechanics (teleport, trap).
- Stats (stored in
src/lib/game/atlasDefinitions.ts):movement(1–4): maximum squares the unit may traverse per turn.attack(0–10): base damage value used in hit/damage formulas.range(1–3): Manhattan distance for ranged attacks.armor(0–5): flat damage reduction against incoming strikes.maxHealth(5–20): starting HP each match, tracked in state.ability(optional descriptor): name, cooldown, and effect parameters.
- Loadouts: A player selects four units before battle.
- Loadouts are versioned and stored server‑side; each slot holds a unit ID and custom name.
- Restrictions prevent duplicate archetypes in a loadout unless unlocked via progression.
- Backend tables (
loadouts) record creation time and owner user ID. See migration20260225000000_loadouts.sqlfor schema. - The UI for loadouts is in
src/lib/components/LoadoutSelector.svelte.
- Maps are 8×8 grids, generated heuristically by
src/lib/game/mapData.ts.- Obstacles (
tree,rock,bush) are placed randomly but never in adjacent clusters larger than three tiles. - Each terrain type confers modifiers: e.g. trees grant
+1evasion, rocks block line‑of‑sight entirely. - Start zones are predefined corners; squads deploy within a 2‑tile radius of their corner.
- Obstacles (
- Generation occurs on the server at match start; a serialised map object is persisted in the
matchestable.
- A match state is a JSON object containing:
interface MatchState { map: Tile[][]; units: Record<string, UnitState>; turn: 'player' | 'ai'; turnNumber: number; eventLog: string[]; }
- The engine (
src/lib/game/canvasRenderer.ts+matchState.svelte.ts) applies state diffs streamed from the server. - On the active turn, the side may issue exactly one action per unit: move, attack, ability, or pass.
- Movement uses A* pathfinding (see
src/lib/game/spriteAnimator.ts) that respects terrain costs. - Attacks queue animations and resolve immediately on the server to avoid desync.
- Abilities often change state (heal +5, stun for 1 turn); cooldowns are tracked in
UnitState.cooldowns.
- Movement uses A* pathfinding (see
- After the player finishes, the client sends the full state to
/api/match/ai-move.
- When an attack command is received, the server runs
src/lib/game/engine/combat.ts:- Calculate distance and verify range.
- Determine hit probability:
const hitChance = baseChance + attacker.attack * 2 - defender.evasion + terrainBonus;
- Roll a random number; if miss, log "{attacker} missed {defender}".
- On hit, damage = attacker.attack - defender.armor + random(-2,2).
- Apply damage; if health ≤ 0 emit
unitEliminatedevent and remove from state. - Apply secondary effects (knockback, stun) if the attacking ability includes them.
- Knockback pushes the defender one tile away along the attack vector; the server checks for collisions.
- Stunned units skip their next action; the state keeps a
stunTurnscounter.
- Abilities are defined in
src/lib/game/abilities.tsas pure functions takingMatchStateand returning modifications. - Examples:
healAlly: restore 5 HP to the lowest‑health nearby ally.arrowVolley: perform three ranged attacks in a line.teleport: swap positions with any visible unit, cooldown 3.
- Cooldowns are decremented at the start of the owning side's turn; abilities cannot be used when
cooldown > 0.
The AI uses a large language model to decide and narrate its moves. The flow is:
- Serialization:
src/lib/server/match/ai.tsconverts theMatchStateinto a plain-text prompt. Example excerpt:Map coordinates: A1: tree, B1: empty... Player units: Alice (Melee, 12 HP) at C3; Bob (Ranged, 8 HP) at D4... It's the AI's turn. Provide up to 4 actions in JSON with narrative. - Caching: Before calling OpenRouter the server checks Redis (
src/lib/server/redis.ts) for an identical prompt key; cached responses expire after 30 seconds to avoid redundant LLM calls during a single turn. - Model call: The prompt is sent via SSE to OpenRouter; the stream yields a JSON object of moves and a text snippet. The server merges these into a
MatchActionrecord. - Execution: The server validates the moves (range checks, legality) and applies them to state using the same combat engine.
- Event log: The AI narrative is appended to
state.eventLogand sent back to the client. - Retries: If the model output fails validation (malformed JSON), the server retries up to 2 times with a simplified prompt.
The LLM also generates the initial tutorial script and may be queried outside matches for flavor (e.g. unit descriptions).
- Tutorial definitions reside in
src/lib/tutorial/tutorialSteps.tsas an array of objects:interface TutorialStep { message: string; requiredAction?: 'move' | 'attack' | 'ability'; targetUnitId?: string; onComplete?: () => void; }
tutorialController.svelte.tsmaintains acurrentStepindex and watches player actions. It prevents progression until the requiredAction occurs on the specified unit.- Screenshots and overlays are handled in
TutorialOverlay.svelte. - The tutorial can be restarted from the lobby; progress (completed flag) is tracked per user in Supabase.
- Match creation endpoint
/api/match/createdoes:- Generate map and initial states.
- Insert a row into
matcheswith a JSONstatefield andowner_id. - Return match ID to client for polling.
- Client polls
/api/match/{id}/stateevery 500 ms during play. Only the diff from the last known state is sent to reduce bandwidth; the diff is computed server‑side usingjsondiffpatch. - When the game ends (turn limit = 50 or one side eliminated), the server updates the row with
finished=true,winner='player'|'ai', and storesfinalEventLog. - A separate
/api/match/{id}/statsendpoint returns aggregated data (turns taken, damage dealt) for the results screen. - Loadouts and match records enable simple progression mechanics (unlock units after 10 wins, etc.).
- State management uses Svelte stores defined in
src/lib/game/matchState.svelte.ts; stores are recreated per match. - Rendering is done on an HTML canvas in
TileMapCanvas.sveltewith offscreen buffering for performance. - Input handling converts mouse/touch events into grid indices and dispatches actions to the server.
- The event log component (
EventLog.svelte) scrolls automatically as entries arrive; entries may contain Markdown-style formatting for italics. - Inter-component communication uses custom events rather than a global store for anything not strictly stateful.
- See
supabase/migrationsfor full schemas. Key tables:users: standard Supabase auth users.loadouts:(id, owner_id, slots jsonb, created_at).matches:(id, owner_id, state jsonb, finished boolean, winner text, created_at).
Foreign keys and RLS policies ensure users only see their own loadouts and matches.
The application was created as an MVP to showcase server‑driven game logic, persistent loadouts, and smooth client rendering.
The application was created as an MVP to showcase server‑driven game logic, persistent loadouts, and smooth client rendering.
The project uses Supabase for authentication and persisting game state (matches, loadouts), Upstash Redis for short‑term caching, and OpenRouter to generate AI decisions and commentary. Server‑side code handles all sensitive operations; the client is responsible only for rendering state and sending player actions.
- install dependencies
npm install
- start dev server
npm run dev # or `npm run dev -- --open` to open in browser - run type checks
npm run check
- run the full test suite
npm test
On first clone, you may also need to create a local Supabase project and populate
.envwith the usual credentials. Never commit.env.
| Command | Purpose |
|---|---|
npm run dev |
start Vite/SvelteKit in development |
npm run build |
build for production |
npm run preview |
preview production build |
npm run check |
svelte-check TypeScript validation |
npm run lint |
run Prettier against repo |
npm run format |
format source with Prettier |
npm test |
unit + e2e tests |
npm run test:unit |
Vitest unit tests |
npm run test:e2e |
Playwright browser tests |
Directory highlights:
src/routes– file‑based SvelteKit routing with server loadssrc/lib/components– reusable Svelte components (all <200 lines)src/lib/server– server‑only logic: Supabase client, Redis, OpenRoutersrc/lib/game– core game engine (rendering, state, animation)src/lib/tutorial– tutorial steps and controllersupabase/migrations– SQL schema for game tables & loadouts
The application runs mostly on the server; all authenticated database queries happen in +page.server.ts or API endpoints. Redis and OpenRouter are accessed from the server folder only.
- Svelte 5 only: use
$state()/ $derived()/ $effect()/ $props(); never legacy reactive syntax. - Event handlers are plain
onclick/oninput. - No default exports; always named exports.
- TypeScript is strict; avoid
any. - Tailwind CSS utility classes, no custom CSS files.
- Components stay under 200 lines; extract logic when needed.
- CamelCase for variables, PascalCase for components.
- Redis operations live in
src/lib/server/redis.ts. - OpenRouter calls live in
src/lib/server/openrouter.ts, streaming via SSE. - Supabase client initialized in
src/lib/supabaseClient.ts. - Always use server-side loads for authenticated queries.
- Row Level Security must remain enabled in Supabase.
- Supabase handles auth and stores game/match state.
- Use server endpoints (
src/routes/api/...) for secret work. - See migrations for table structure.
- Unit: Vitest covers utility functions and game logic.
- E2E: Playwright simulates a browser playing through the app; located in
e2e/.
Run npm run test to execute both suites.
Internal design notes, API reference, and development instructions live under docs/.
Consult them when adding features or debugging behavior.
- Run
npm run checkbefore committing. - Use
npm run formatto keep styles consistent. - Avoid leaking secret keys; they belong only in server code and
.env.