Imported from kunchenguid/trial-by-combat (
AGENTS.md). Install upstream withnpx skills add kunchenguid/trial-by-combat. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents when working with code in this repository.
Project
Trial by Combat is a turn-based deterministic 1v1 LLM duel, livestream-ready. The current (and only) mode is Capture the Relic on a 9x9 grid. Players are LLM agents that interact via a plain-text HTTP API; spectator and admin views remain browser-based. The server implementation and tests are the source of truth for the player surface.
Commands
npm install
npm start # node src/server.js, default PORT=4178
npm test # node --test, runs all test/*.test.js
npm run test:engine # engine tests only
npm run lint # Biome lint, format, and import checks
node --test test/server.test.js # single file
node --test --test-name-pattern="creates Center Choke" test/engine.test.js # single test
npm run build:atlas # regenerate sprite atlas (see "Sprite atlas" below)
There is no bundler, transpiler, or TypeScript. Pure ESM Node + vanilla browser JS, checked with Biome.
URL routes
Browser (spectator and admin only):
/?player=spectate- spectator view/?player=admin- admin (series length, pause/resume, restart, next game)/?player=1and/?player=2return 404 (player slots are API-only)
WebSocket endpoint /ws is for spectator and admin only; a player WS upgrade is rejected.
Player HTTP API:
GET /player1andGET /player2- long-poll text view + briefing + DO NEXT blockPOST /player1/{join,ready,action,leave}(and/player2/...) - JSON bodies, plain-text replies
Architecture
Two-process boundary: a pure synchronous game engine and a thin HTTP + WebSocket harness that drives it.
src/engine.js - pure game logic
- Exports:
createGame,createSeries,resolveTurn,validateAction,getLegalActions,getPlayerView,getSpectatorView, plusACTIONS,SIDES,BOARD_SIZE,RULESET_VERSION. - All state mutations go through
resolveTurn(game, { blue, red }), which clones the game (cloneGame) and returns{ game, events, actions, droppedByDamage }. Never mutate a game object in place outsideresolveTurn- everything is built around treating game state as immutable from the harness's perspective. - Resolution order in
resolveTurnmatters and is tested: invalid-action coercion to WAIT → respawn stunned → HEAL → SCAN → dash inventory decrement → PLACE_TRAP (early so opponents stepping into the target this turn trigger it) → 2-step movement (with collision detection between sides) → ATTACK → damage application (GUARD reduces by 2) → forced relic drops on >=3 damage → voluntary DROP_RELIC → knockouts → PLACE_WALL (late so walls can't retroactively block in-flight moves) → auto pickup → win check → turn cap. - Wall placement runs a path invariant check (
allPathInvariantsHold) on a cloned trial game so a player can't seal off the relic or either base. BFS viashortestPath. - Sides are fixed for the series:
slotSidesForGamealways returns{ player_1: 'blue', player_2: 'red' }regardless of game number. The function exists as a hook in case we re-enable swapping, but today neither slots nor sides flip between games. - Map constants (
CENTER_CHOKE) and starting inventory are frozen module locals. To add a new map, parameterizecreateGamerather than mutating these.
src/server.js - HTTP + WebSocket harness
createAppServer({ turnSeconds })returns{ app, server, listen, close, port }. Tests use this with a randomly-assigned port and shortturnSeconds.- Holds a single in-memory
state(no DB, no persistence). Phases:pre_lobby→lobby→match→ (game_end|series_end). - Player slots are HTTP-only. State stores
{ name, ready }per slot - no socket. The slot is "held" while a name is set;POST /playerN/leaveclears it (and pauses an active match). - Long-polling for
GET /playerNis driven by a per-stateEventEmitter-notifyChange(state)both wakes the long-pollers and broadcasts to spectator/admin WS. - Turn timer is a single
setTimeout; on expiry, any side without apendingActionsentry is auto-WAIT'd. Pause/resume preserves remaining seconds inremainingWhenPaused. - Validation has two strikes per turn: first invalid action returns 400 with a retry hint; second invalid action this turn locks the side as WAIT.
- Action body translation: HTTP uses uniform
{action, target, intent}. The harness translatesMOVE/DASH+targetcoord into the engine's directionalMOVE_NORTH/DASH_EASTshape;PLACE_WALL/PLACE_TRAPkeep their target coord;ATTACKis untargeted; intent maps tointent_summary. - Spectator/admin still receive
{ type: 'state', role, state }over/ws:- Spectator view:
getSpectatorViewwith optional X-ray (set_xraytoggle on the WS). - Admin view: full payload + spectator view forced to xray.
- Spectator view:
- Player view rendering for the API is implemented as text (grid + metadata + DO NEXT) directly in
server.js.
public/ - browser client
- No build step.
index.htmlloads Pixi.js from CDN andapp.jsas a module. Asset modules are imported with?v=...query strings as cache-busters; bump the version when changing the asset or its consumer. app.jsconnects to/ws, dedupes incoming state with a fingerprint that strips the timer fields, then renders one of two roles (spectator/admin). Player rendering is gone - those slots are API-only.- Visuals use the sprite atlas at
public/assets/trial-by-combat-sprite-sheet.pngplus the generatedsprite-atlas.jsruntime metadata.
Sprite atlas
scripts/build-sprite-atlas.mjs reads the source PNG + JSON in public/assets/source/, validates strict invariants (2048x2048, 64px cells, 32x32 grid), then writes:
public/assets/trial-by-combat-sprite-sheet.png(copied)public/assets/trial-by-combat-sprite-sheet.meta.json(runtime metadata)public/assets/sprite-atlas.js(runtime ESM module)
Re-run npm run build:atlas after editing any source asset. The atlas version (production-atlas-2048-v2) is hard-coded in the script and must match the ?v= cache-buster used by app.js.
Conventions to keep
- Coordinates are letter+digit strings (
A1-I9); usecoordToPoint/pointToCoord/stepCoordrather than parsing inline. - Engine functions take a side (
'blue'/'red') at the boundary; convert from slot viagame.slotSides/game.sideSlots. - Events have
visibility: 'public' | 'private_blue' | 'private_red'. Player views filter viavisibleEventsFor; never leak aprivate_*event to the wrong side. - Tests use the built-in
node:testrunner (no Jest, no Mocha). API tests intest/api.test.jsspin upcreateAppServeron port 0 and use realfetch; spectator/admin WS coverage lives intest/server.test.js. - When changing rules, also bump
RULESET_VERSIONinengine.jsand update server tests if the player surface is affected.
Maintaining this file
Keep this file for knowledge useful to almost every future agent session in this project. Do not repeat what the codebase already shows; point to the authoritative file or command instead. Prefer rewriting or pruning existing entries over appending new ones. When updating this file, preserve this bar for all agents and keep entries concise.