Skip to content

ForLoopCodes/airena

Repository files navigation

eval-arena

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.

Game details

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.

Units and loadouts

  • 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 migration 20260225000000_loadouts.sql for schema.
    • The UI for loadouts is in src/lib/components/LoadoutSelector.svelte.

Map generation

  • 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 +1 evasion, rocks block line‑of‑sight entirely.
    • Start zones are predefined corners; squads deploy within a 2‑tile radius of their corner.
  • Generation occurs on the server at match start; a serialised map object is persisted in the matches table.

Turn structure

  • 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.
  • After the player finishes, the client sends the full state to /api/match/ai-move.

Combat resolution

  • When an attack command is received, the server runs src/lib/game/engine/combat.ts:
    1. Calculate distance and verify range.
    2. Determine hit probability:
      const hitChance = baseChance + attacker.attack * 2 - defender.evasion + terrainBonus;
    3. Roll a random number; if miss, log "{attacker} missed {defender}".
    4. On hit, damage = attacker.attack - defender.armor + random(-2,2).
    5. Apply damage; if health ≤ 0 emit unitEliminated event and remove from state.
    6. 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 stunTurns counter.

Abilities and effects

  • Abilities are defined in src/lib/game/abilities.ts as pure functions taking MatchState and 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.

AI opponent

The AI uses a large language model to decide and narrate its moves. The flow is:

  1. Serialization: src/lib/server/match/ai.ts converts the MatchState into 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.

  2. 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.
  3. 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 MatchAction record.
  4. Execution: The server validates the moves (range checks, legality) and applies them to state using the same combat engine.
  5. Event log: The AI narrative is appended to state.eventLog and sent back to the client.
  6. 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 engine

  • Tutorial definitions reside in src/lib/tutorial/tutorialSteps.ts as an array of objects:
    interface TutorialStep {
      message: string;
      requiredAction?: 'move' | 'attack' | 'ability';
      targetUnitId?: string;
      onComplete?: () => void;
    }
  • tutorialController.svelte.ts maintains a currentStep index 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 lifecycle and persistence

  • Match creation endpoint /api/match/create does:
    • Generate map and initial states.
    • Insert a row into matches with a JSON state field and owner_id.
    • Return match ID to client for polling.
  • Client polls /api/match/{id}/state every 500 ms during play. Only the diff from the last known state is sent to reduce bandwidth; the diff is computed server‑side using jsondiffpatch.
  • When the game ends (turn limit = 50 or one side eliminated), the server updates the row with finished=true, winner='player'|'ai', and stores finalEventLog.
  • A separate /api/match/{id}/stats endpoint 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.).

Client architecture

  • 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.svelte with 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.

Data and schemas

  • See supabase/migrations for 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.


Quick start

  1. install dependencies
    npm install
  2. start dev server
    npm run dev
    # or `npm run dev -- --open` to open in browser
  3. run type checks
    npm run check
  4. run the full test suite
    npm test

On first clone, you may also need to create a local Supabase project and populate .env with the usual credentials. Never commit .env.


Scripts

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

Architecture

Directory highlights:

  • src/routes – file‑based SvelteKit routing with server loads
  • src/lib/components – reusable Svelte components (all <200 lines)
  • src/lib/server – server‑only logic: Supabase client, Redis, OpenRouter
  • src/lib/game – core game engine (rendering, state, animation)
  • src/lib/tutorial – tutorial steps and controller
  • supabase/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.


Conventions

  • 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.

Authentication & Database

  • Supabase handles auth and stores game/match state.
  • Use server endpoints (src/routes/api/...) for secret work.
  • See migrations for table structure.

Testing

  • 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.


Documentation

Internal design notes, API reference, and development instructions live under docs/. Consult them when adding features or debugging behavior.


Tips

  • Run npm run check before committing.
  • Use npm run format to keep styles consistent.
  • Avoid leaking secret keys; they belong only in server code and .env.

About

airena 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.

Topics

Resources

License

Stars

Watchers

Forks

Contributors