Map Editor Documentation

Web APIs & Headless SDK

Complete REST API reference and Headless Engine SDK for publishing maps, querying leaderboards, and running automated headless simulations.

Page 16 of 17

Shards of Stone provides a comprehensive web backend and headless simulation suite designed for custom map distribution, community matchmaking, competitive analytics, and automated testing.

This reference covers:

  1. REST APIs: Public and authenticated endpoints for discovering and publishing custom maps, joining multiplayer lobbies, retrieving competitive leaderboards, and generating generative assets.
  2. Headless Engine SDK (HeadlessGame): A pure Node.js simulation engine that runs the full deterministic game loop up to 1000× real-time with zero DOM, Canvas, or WebGL dependencies for balance tuning, AI benchmarking, and scenario verification.
  3. Client Embed SDK & postMessage RPC Protocol: Embed Shards of Stone matches directly inside external portals, learning management systems, and gaming communities with bi-directional postMessage RPC bridging and standalone .sosmod packaging.

Part 1: REST API Reference

All REST endpoints operate over HTTP/JSON. Authenticated requests use session cookies (session=<token>) or the Authorization: Bearer <session_token> header.

flowchart LR
    Client["Client / Custom Tools"] --> API["Next.js Route Handlers (/api/*)"]
    API --> DB[("Turso SQLite DB (libsql)")]
    API --> GenAI["Generative Providers (Gemini / ElevenLabs)"]
    API --> PeerNet["PeerJS / Lockstep Signaling"]

1. Community Maps API

GET /api/maps

Lists and filters community and user-authored custom maps.

  • Query Parameters:

    • published (boolean, optional): When true, returns all public, published community maps (no authentication required).
    • mine (boolean, optional): When true, returns maps created by the authenticated user (requires auth).
    • search (string, optional): Substring search against map titles.
    • sort (string, optional): Sort order. Options: 'newest' (default), 'rating', 'popular', 'name'.
    • page (integer, optional, default: 1): Page number.
    • limit (integer, optional, default: 12, max: 50): Results per page.
  • Response 200 OK:

{
  "maps": [
    {
      "id": "cm_8f3a12b4",
      "name": "Iron Peak Fortress",
      "description": "A 4-player defensive mountain hold with chokepoints and gold veins.",
      "width": 128,
      "height": 128,
      "map_type": "standard",
      "biome": "snow",
      "player_count": 4,
      "is_published": 1,
      "thumbnail": "data:image/png;base64,...",
      "play_count": 342,
      "avg_rating": 4.85,
      "rating_count": 27,
      "created_at": 1726000000000,
      "updated_at": 1726100000000,
      "creator_username": "ThorgarIronbeard"
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 12,
    "total": 1,
    "totalPages": 1
  }
}

GET /api/maps/[id]

Fetches full scenario metadata and the complete serialized JSON map payload.

  • Path Parameters:

    • id (string): The unique map identifier.
  • Behavior: Automatically increments the map's play_count counter. Unpublished drafts can only be fetched by the map's creator.

  • Response 200 OK:

{
  "map": {
    "id": "cm_8f3a12b4",
    "name": "Iron Peak Fortress",
    "description": "A 4-player defensive mountain hold.",
    "width": 128,
    "height": 128,
    "map_data": "{\"version\":1,\"tiles\":[...],\"spawnPoints\":[...],\"resources\":[...],\"triggers\":{...}}",
    "creator_username": "ThorgarIronbeard",
    "play_count": 343,
    "avg_rating": 4.85,
    "rating_count": 27
  }
}

POST /api/maps/[id]/publish

Publishes or unpublishes a custom map scenario. When publishing, the server executes strict validation and generates a cryptographic content hash.

  • Authentication: Required (must be map author).
  • Request Body:
{
  "publish": true
}
  • Validation Rules: The publishing route executes the authoritative server-side map validator before setting is_published = 1:

    1. Minimum Spawn Points: Map must define at least 2 valid player spawn points within bounds.
    2. Economic Viability: Map must include at least 1 accessible gold mine tile or resource deposit.
    3. No Footprint Overlaps: Authored buildings, doodads, and starting units must not occupy conflicting grid cells.
    4. Elevation Invariants: Ramp slopes and cliff edges must obey height-stride limits.
  • Response 200 OK:

{
  "is_published": true
}
  • Error 400 Bad Request:
{
  "error": "Map must have at least 2 spawn points to publish"
}

POST /api/maps/[id]/rate

Submits or updates a 1–5 star player rating and optional feedback comment.

  • Authentication: Required.
  • Rules: Users cannot rate their own maps. Maps must be published. Ratings are upserted per user.
  • Request Body:
{
  "rating": 5,
  "comment": "Incredible tower defense wave pacing. Difficult final boss!"
}
  • Response 200 OK:
{
  "avg_rating": 4.88,
  "rating_count": 28
}

2. Multiplayer Lobby & Lockstep Matchmaking

GET /api/multiplayer/games

Lists active public multiplayer game lobbies waiting for players. Automatically prunes stale lobbies whose host heartbeat has lapsed for more than 10 minutes.

  • Response 200 OK:
[
  {
    "id": "game_7c99e2",
    "hostName": "Valdor",
    "hostRace": "dwarf",
    "mapSize": "medium",
    "mapType": "continents",
    "biome": "alpine",
    "status": "waiting",
    "totalSlots": 4,
    "humanSlots": 4,
    "joinedHumans": 2,
    "spectators": 0,
    "maxSpectators": 4,
    "gameVersion": "v2.5.0",
    "createdAt": 1726480000000
  }
]

POST /api/multiplayer/games/[id]/join

Reserves a player or spectator slot in a lobby and initiates the peer-to-peer lockstep handshake.

  • Request Body:
{
  "password": "optional_room_password",
  "asSpectator": false
}
  • Slot Reservation Invariant: The database executes an atomic UPDATE ... WHERE joined_humans < human_slots query. If two peers click the final seat simultaneously, the loser immediately receives a 409 Conflict instead of overflowing the lobby.

  • Response 200 OK:

{
  "id": "game_7c99e2",
  "seatNumber": 2,
  "asSpectator": false,
  "hostPeerId": "shards-host-game_7c99e2",
  "lockstepTurnLength": 3
}

3. Leaderboard, Ratings & Match Outcomes

Competitive standings use a Bayesian rating framework (similar to TrueSkill/Wheng-Lin) parameterized by latent skill $\mu$ and uncertainty $\sigma$.

GET /api/leaderboard

Queries global player rankings and combat stats.

  • Query Parameters:

    • sort: 'score' (default), 'wins', 'winrate', or 'highest'.
    • limit: Number of entries (default: 50, max: 100).
    • offset: Pagination offset (default: 0).
  • Response 200 OK:

{
  "players": [
    {
      "rank": 1,
      "username": "GloinTheGreat",
      "avatarId": "dwarf_thane",
      "totalScore": 142850,
      "highestScore": 12400,
      "wins": 48,
      "losses": 6,
      "gamesPlayed": 54,
      "winRate": 89,
      "totalUnitsKilled": 3480,
      "totalBuildingsRazed": 412
    }
  ],
  "total": 1280,
  "currentUser": {
    "rank": 42,
    "username": "MyPlayerName",
    "totalScore": 31200,
    "wins": 12,
    "losses": 8,
    "gamesPlayed": 20,
    "winRate": 60
  }
}

GET /api/ratings

Fetches Trueskill-derived ratings for auto-balancing lobbies.

  • Query Parameters:

    • names (string): Comma-separated list of usernames (max 16).
    • mode (string): Rating queue mode ('1v1', 'team', 'ffa').
  • Rating Formulas:

    • Match Balancing Rating: matchRating = μ - 1.0σ
    • Displayed Leaderboard Rating: rating = μ - 3.0σ
  • Response 200 OK:

{
  "mode": "team",
  "ratings": {
    "GloinTheGreat": {
      "mode": "team",
      "matchRating": 28.45,
      "rating": 22.10,
      "games": 54,
      "provisional": false
    }
  }
}

POST /api/auth/update-stats

Authoritative settlement endpoint for reporting match results and updating ratings.

  • Security & Asymmetric Corroboration Ledger: Match outcomes require consensus across peers. The endpoint writes claims to the match_results ledger keyed by (mp_match_id, user_id). A victory claim remains uncommitted until corroborated by peer reports, preventing client-side spoofing.
  • Request Body:
{
  "matchId": "match_98b2e104",
  "outcome": "win",
  "score": 4500,
  "durationSeconds": 920,
  "stats": {
    "unitsKilled": 85,
    "buildingsRazed": 12,
    "resourcesGathered": 14200
  }
}

4. Generative AI Asset & Audio Hooks

These endpoints connect to generative backends during map creation and dev tooling. Both routes are guarded by devOnlyGuard() and reject calls in production environments unless running in local development or staging.

POST /api/generate-asset

Invokes the Gemini image-generation pipeline (API_KEY, API_URL) to synthesize sprites, building concepts, or minimap art.

  • Request Body:
{
  "prompt": "Isometric 3/4 RTS dwarf steam tank with brass boiler and iron treads, neutral magenta background"
}
  • Response 200 OK:
{
  "candidates": [
    {
      "content": {
        "parts": [{ "inlineData": { "mimeType": "image/png", "data": "base64..." } }]
      }
    }
  ]
}

POST /api/generate-sound

Invokes the ElevenLabs sound-effect synthesis API (ELEVEN_LABS_API_KEY) for unit voice lines, spell audio, and ambient cues.

  • Request Body:
{
  "text": "Deep stone grinding and mechanical steam hiss",
  "duration_seconds": 2.5
}
  • Response 200 OK: Returns an audio/mpeg binary buffer stream.

Part 2: Headless Simulation SDK (HeadlessGame)

The HeadlessGame harness (tools/ai_loop/HeadlessGame.ts) runs complete RTS matches in pure Node.js.

flowchart TD
    Config["HeadlessGameConfig (Seed, Races, Map, Slots)"] --> Init["await game.init()"]
    Init --> Loop["Simulation Loop (while playing && ticks < maxTicks)"]
    
    subgraph SimEng["Pure Node.js Sim Engine (0 DOM / 0 Canvas)"]
        World["ECS World (Canonical ID Ordering)"]
        Combat["CombatSystem"]
        Movement["MovementSystem"]
        Path["NavGrid / SpatialGrid / FlowFields"]
        AI["AISystem / LLMAISystem"]
        Triggers["TriggerSystem (AST Total Eval)"]
        
        World --- Combat
        World --- Movement
        World --- Path
        World --- AI
        World --- Triggers
    end
 
    Loop --> SimEng
    SimEng --> Stats["ResultCollector (APM, Economy, Deadliest Units)"]
    Stats --> Res["GameResult (Winner, Ticks, Stats JSON)"]

Why Run Headless?

  1. Zero Presentation Overhead: By stripping Canvas 2D, Three.js 3D meshes, WebGL buffers, and HTML audio, the engine executes up to 1000× faster than real-time. A standard 30-minute match (36,000 ticks) simulates in under 20 seconds on a modern CPU.
  2. Deterministic Parity with Browser: HeadlessGame registers the exact same ECS systems at the exact same priorities as the live browser game (Game.ts).
  3. Core Use Cases:
    • Balance Tuning: Run 1,000 matches overnight (Dwarves vs Goblins) across diverse seeds to calculate win rate deviations, unit kill/death ratios, and resource pacing.
    • AI Benchmarking: Evaluate LLM-driven agents against rule-based heuristic bots.
    • Scenario Quality Assurance: Verify that custom maps contain no unreachable spawn islands, resource starvation bugs, or infinite trigger loops.

HeadlessGame Configuration Schema

export interface HeadlessGameConfig {
  /** Map generation seed. REQUIRED for reproducible deterministic runs. */
  mapSeed: number;
  /** Map size preset ('small' = 64, 'medium' = 96, 'large' = 128). */
  mapSize: 'small' | 'medium' | 'large';
  /** Map topology ('standard', 'islands', 'continents', 'coastal'). */
  mapType: MapType;
  /** Player roster configuration. Supports 2 to 8 players. */
  slots?: Array<{
    playerId?: number;
    race: 'dwarf' | 'goblin' | 'ratmen' | 'lizardmen' | 'wood_elf' | 'high_elf' | 'dark_elf';
    difficulty?: 'easy' | 'medium' | 'hard';
    isAI?: boolean;
    teamId?: number;
  }>;
  /** Maximum ticks before declaring a stalemate (default: 72,000 = 60 minutes). */
  maxTicks?: number;
  /** Periodic stdout logging interval in ticks (default: 600 = 30 seconds). */
  logInterval?: number;
  /** Optional raw custom map JSON string to load an authored scenario. */
  customMapJson?: string;
  /** Per-tick hook for telemetry capture or mid-match command injection. */
  onTick?: (tick: number, game: HeadlessGame) => void;
}

Complete Code Recipe: Running an Automated Simulation

The recipe below demonstrates how to configure a headless match, inject player commands, run 10,000 ticks, and inspect final combat telemetry.

import { HeadlessGame } from '@/../tools/ai_loop/HeadlessGame';
import type { HeadlessGameConfig } from '@/../tools/ai_loop/HeadlessGame';
import { TICKS_PER_SECOND } from '@/game/data/constants';
 
async function runAutomatedScenario() {
  console.log('--- Initializing Headless Simulation ---');
 
  const config: HeadlessGameConfig = {
    mapSeed: 42891,
    mapSize: 'medium',
    mapType: 'standard',
    maxTicks: 10000, // Simulate ~8.3 minutes of gameplay
    logInterval: 1200, // Log progress every 60 game seconds
    slots: [
      { playerId: 0, race: 'dwarf', difficulty: 'hard', teamId: 0 },
      { playerId: 1, race: 'goblin', difficulty: 'hard', teamId: 1 },
    ],
    onTick: (tick, game) => {
      // Example: Injecting mid-game command on Tick 100
      if (tick === 100) {
        console.log('[Hook] Tick 100: Dispatching emergency scout order for Player 0');
        game.eventBus.emit('request-train', {
          unitTypeId: 'dwarf_miner',
          playerId: 0,
        });
      }
    },
  };
 
  const game = new HeadlessGame(config);
 
  // 1. Initialize simulation (builds NavGrid, spawns starting bases, boots ECS)
  await game.init();
 
  const startWallTime = performance.now();
 
  // 2. Run simulation loop to completion or maxTicks
  const result = await game.run();
 
  const totalWallSeconds = ((performance.now() - startWallTime) / 1000).toFixed(2);
  const simGameMinutes = (result.ticks / TICKS_PER_SECOND / 60).toFixed(1);
  const speedupMultiplier = Math.round((result.ticks / TICKS_PER_SECOND) / (Number(totalWallSeconds) || 1));
 
  // 3. Inspect and assert results
  console.log('\n--- Simulation Completed ---');
  console.log(`Wall clock time:   ${totalWallSeconds}s`);
  console.log(`Simulated time:    ${result.ticks} ticks (${simGameMinutes} minutes)`);
  console.log(`Execution speed:   ${speedupMultiplier}x real-time`);
  console.log(`Winner:            Player ${result.winner} (${result.winnerRace})`);
  console.log(`Outcome Reason:    ${result.reason}`);
 
  // 4. Output seat breakdown
  for (const seat of result.seats) {
    console.log(`\n[Seat ${seat.playerId} - ${seat.race}]`);
    console.log(`  Units Produced:     ${seat.unitsProduced}`);
    console.log(`  Units Slain:        ${seat.kills}`);
    console.log(`  Units Lost:         ${seat.losses}`);
    console.log(`  Buildings Razed:    ${seat.buildingsRazed}`);
    console.log(`  Gold Harvested:     ${seat.totalGathered.gold}`);
    console.log(`  Lumber Harvested:   ${seat.totalGathered.lumber}`);
    console.log(`  Peak Military Pop:  ${seat.peakArmySupply}`);
  }
}
 
// Execute
runAutomatedScenario().catch(console.error);

Inspecting Outcome Telemetry (GameResult)

The returned GameResult object provides detailed metrics collected during the run:

export interface GameResult {
  readonly winner: number;             // Winning player ID (-1 for draw/stalemate)
  readonly winnerRace: string;
  readonly ticks: number;              // Total ticks simulated
  readonly reason: 'elimination' | 'wonder' | 'regicide' | 'stalemate' | 'max_ticks';
  readonly seats: Array<{
    readonly playerId: number;
    readonly race: string;
    readonly kills: number;
    readonly losses: number;
    readonly unitsProduced: number;
    readonly buildingsRazed: number;
    readonly totalGathered: {
      readonly gold: number;
      readonly lumber: number;
      readonly stone: number;
      readonly oil: number;
    };
    readonly finalScore: number;
    readonly peakArmySupply: number;
  }>;
}

Part 3: Client Embed SDK & postMessage RPC Protocol

Shards of Stone matches can be embedded seamlessly into web portals, LMS platforms, external tournament websites, or custom modding tools using a standard <iframe> and bi-directional postMessage RPC protocol.

sequenceDiagram
    autonumber
    actor Host as Host Web Application
    participant Iframe as Shards of Stone (&lt;iframe&gt;)
    participant Engine as Game Engine & EventBus
 
    Host->>Iframe: Mount &lt;iframe src="/play?embed=1&amp;customMap=..."&gt;
    Iframe-->>Host: postMessage { type: "sos:ready" }
    Iframe->>Engine: game.start()
    Engine-->>Host: postMessage { type: "sos:match-start", payload: { tick: 0 } }
 
    rect rgb(20, 25, 40)
        Note over Host,Engine: Active Match Loop
        Host->>Iframe: postMessage { type: "sos:pause" }
        Host->>Iframe: postMessage { type: "sos:resume" }
        Engine-->>Host: postMessage { type: "sos:trigger-event", payload: { ...data, tick } }
    end
 
    Engine-->>Host: postMessage { type: "sos:match-end", payload: { winner, tick, state } }

1. Iframe Embedding & Query Parameters

To embed the game client inside an external host page, mount an <iframe> pointing to the /play route with the embed=1 parameter:

<iframe
  id="game-viewport"
  src="https://shardsofstone.com/play?embed=1&customMap=siege_of_iron&mod=chaos_rebalance"
  width="1280"
  height="720"
  allow="autoplay; fullscreen"
  style="border: none; width: 100%; height: 100vh;"
></iframe>

Supported Query Parameters

ParameterTypeDescription
embed1Enforces full-bleed canvas layout, suppresses standalone page headers, hides window cramp warnings, and bypasses guest/auth gates.
customMapstringScenario ID or filename (e.g. siege_of_iron or session). Automatically fetched from /maps/<name>.json or loaded from session storage.
modstringMod identifier or package name (e.g. chaos_rebalance). Resolves against /mods/<id>.sosmod, /mods/<id>.json, or active session mod storage.

2. Standalone Mod Package (.sosmod) Specification

Standalone mod packages (.sosmod) encapsulate balance patches, definition overrides, and trigger scripts as portable, redistributable JSON archives. They can be authored in The Forge Map Editor via File -> Export Standalone Mod (.sosmod)... or imported through File -> Import Standalone Mod (.sosmod)... and the AI Import panel.

Mod Package Schema

export interface ModPackage {
  /** Optional JSON schema URI */
  $schema?: string;
  /** Unique mod identifier (slug format: lowercase alphanumeric with hyphens/underscores) */
  id: string;
  /** Human-readable display title */
  name: string;
  /** Semantic versioning string (e.g., "1.2.0") */
  version: string;
  /** Author handle or organization name */
  author: string;
  /** Detailed description of the modifications */
  description: string;
  /** Categorical tags for indexing (e.g. ["balance", "hardcore", "pve"]) */
  tags?: string[];
  /** Primary category grouping */
  category?: string;
  /** Unit and building attribute overrides */
  defOverrides?: DefOverrideSet;
  /** Custom trigger script actions and condition trees */
  triggers?: TriggerScript;
  /** ISO timestamp of packaging */
  created?: string;
}

Layering Semantics (applyModPackageToMap)

When a match launches with a .sosmod package attached:

  1. Base map properties, terrain, and placements remain intact.
  2. defOverrides are deep-merged into the map's definition overrides, preserving unshadowed values.
  3. Variables declared in the mod's triggers.variables are combined with the map's variables.
  4. Triggers defined in triggers.triggers are appended to the map's trigger list, skipping triggers with duplicate IDs.

3. Inbound RPC Messages (Host → Iframe)

Send messages to the game client by posting to the iframe's contentWindow:

sos:load-map

Launches or reloads a custom map dynamically without refreshing the page.

  • Payload Properties:
    • mapData (string | object): Raw serialized map JSON or parsed JavaScript map object.
    • biome (string, optional): Biome override ('forest', 'snow', 'desert', 'wasteland', 'swamp', 'cave').
    • mod (ModPackage, optional): Optional standalone .sosmod package to apply on top of the map.
iframe.contentWindow.postMessage({
  type: 'sos:load-map',
  payload: {
    mapData: mySerializedMapJson,
    biome: 'snow',
    mod: myCustomModPackage,
  },
}, '*');

sos:pause

Pauses the active single-player simulation loop and displays the pause menu.

iframe.contentWindow.postMessage({ type: 'sos:pause' }, '*');

sos:resume

Resumes a paused simulation loop and returns focus to the game viewport.

iframe.contentWindow.postMessage({ type: 'sos:resume' }, '*');

4. Outbound RPC Messages (Iframe → Host)

The game client posts events upward to window.parent:

sos:ready

Dispatched when the play page has mounted, initialized React state, and registered the postMessage event listener.

{
  "type": "sos:ready"
}

sos:match-start

Dispatched immediately when game.start() initiates the fixed-timestep simulation loop.

{
  "type": "sos:match-start",
  "payload": {
    "tick": 0
  }
}

sos:trigger-event

Dispatched whenever a scenario script executes the raiseEvent trigger action. Custom payloads passed to raiseEvent are forwarded verbatim along with the simulation tick.

{
  "type": "sos:trigger-event",
  "payload": {
    "eventName": "boss_defeated",
    "bossId": "dwarf_steam_tank",
    "killerPlayerId": 0,
    "tick": 4200
  }
}

sos:match-end

Dispatched when the match reaches a terminal victory, defeat, or stalemate condition.

{
  "type": "sos:match-end",
  "payload": {
    "winner": 0,
    "tick": 8420,
    "state": 3
  }
}

(Note: state corresponds to GameState.VICTORY = 3, GameState.DEFEAT = 4).


5. Complete Host Integration Example

Below is a complete implementation showing how a host web application can initialize an embedded Shards of Stone iframe, coordinate match loads, and respond to engine telemetry:

// host-app.ts
interface EmbedMessage<T = unknown> {
  type: string;
  payload?: T;
}
 
const iframe = document.getElementById('rts-embed') as HTMLIFrameElement;
 
// 1. Listen for game lifecycle events
window.addEventListener('message', (event: MessageEvent<EmbedMessage>) => {
  if (!event.data || typeof event.data !== 'object') return;
 
  const { type, payload } = event.data;
 
  switch (type) {
    case 'sos:ready':
      console.log('[Host] Shards of Stone embed ready for match commands');
      // Ready to dispatch initial scenario
      break;
 
    case 'sos:match-start':
      console.log('[Host] Match started at simulation tick:', (payload as any)?.tick);
      break;
 
    case 'sos:trigger-event':
      console.log('[Host] Custom scenario trigger event:', payload);
      // Example: award an achievement or update external quest tracker
      break;
 
    case 'sos:match-end': {
      const { winner, tick, state } = payload as { winner: number; tick: number; state: number };
      console.log(`[Host] Match concluded! Winner: Player ${winner} at tick ${tick}`);
      break;
    }
  }
});
 
// 2. Control match state from external buttons
function pauseGame() {
  iframe.contentWindow?.postMessage({ type: 'sos:pause' }, '*');
}
 
function resumeGame() {
  iframe.contentWindow?.postMessage({ type: 'sos:resume' }, '*');
}
 
async function loadScenarioWithMod(mapData: string, modPackage: unknown) {
  iframe.contentWindow?.postMessage({
    type: 'sos:load-map',
    payload: {
      mapData,
      biome: 'wasteland',
      mod: modPackage,
    },
  }, '*');
}

Previous: Engine Hooks & Event Bus. Next: Asset Catalog & Key Registry. Back to: Map Editor Overview.