Map Editor Documentation

Web APIs, Embedding & Headless Runs

The HTTP endpoints for community maps, validation and the asset catalogue; embedding the game in an iframe with postMessage; and running matches headlessly from a checkout of the repository.

Advanced · Page 21 of 46

This chapter is for people building tools around the game: a map site, a Discord bot that validates uploads, a tournament page with an embedded match, an AI agent that writes and tests maps.

Three things are covered, and they have different stability promises:

SurfaceStatus
Community map, validation and asset catalogue endpointsPublic. Documented here and intended for outside use.
The embed (/play?embed=1 and its messages)Public.
Headless runs (tools/ai_loop/HeadlessGame.ts)Internal tooling in the game's repository. Usable, not a package, and may change without notice.

Other endpoints the game client calls (multiplayer lobbies, match results, account and rating routes) exist to serve the game itself. They are not documented as an API and can change with any release.

All endpoints live under https://www.shardsofstone.com/api/. Endpoints that need an account use the signed-in session cookie; there are no API keys.

Community maps

List maps: GET /api/maps

ParameterMeaning
published=truePublic, published maps. No sign-in needed.
mine=trueYour own maps, published or not. Requires sign-in.
searchSubstring match on the map name
categoryFilter by map type (all means no filter)
sortnewest (default), rating or popular
pagePage number, from 1
limitResults per page, default 12, maximum 50

One of published=true or mine=true is required; without either the response is 400.

{
  "maps": [
    {
      "id": "…",
      "name": "…",
      "description": "…",
      "width": 128,
      "height": 128,
      "map_type": "…",
      "biome": "…",
      "player_count": 2,
      "is_published": 1,
      "thumbnail": "…",
      "play_count": 0,
      "avg_rating": 0,
      "rating_count": 0,
      "created_at": "…",
      "updated_at": "…",
      "creator_username": "…"
    }
  ],
  "pagination": { "page": 1, "limit": 12, "total": 1, "totalPages": 1 }
}

The list does not include the map file itself. Your own maps (mine=true) also carry is_hidden and hidden_reason.

Fetch one map: GET /api/maps/{id}

Returns { "map": { … } } with every field above plus map_data, the map file as a JSON string. Unpublished or hidden maps are visible only to their owner. Fetching a map does not count a play. GET /api/maps/{id}?peek=1 returns only { "map": { id, is_published, is_hidden } }, for checking whether a map is still published without downloading it (still subject to the same visibility rules).

Count a play: POST /api/maps/{id}/play

Reports that a match actually started on the map. This is what play_count counts. No sign-in is needed and there is no body. The response is 200 { "counted": true | false }. A play is counted at most once per player per map in any rolling hour, where the player is the signed-in account or, for a guest, a hash of the network address. A repeat inside the hour is counted: false, not an error. Only published maps that are not hidden count. An unpublished or hidden map is a 404 for everyone except its owner (and an admin, for a hidden map), who gets counted: false.

Create, update, delete

RequestNotes
POST /api/mapsSign-in required. Body: name (up to 100 characters), description (up to 2000), width, height, mapType, biome, mapData (up to 4 MB, else 413), thumbnail. Returns 201 { id, validation }.
PUT /api/maps/{id} (or PATCH)Owner only. Same fields, all optional. A published map can be updated in place, but new mapData must pass the publish check or the response is 422 and nothing changes.
DELETE /api/maps/{id}Owner only.

Publish: POST /api/maps/{id}/publish

Owner only. Body: { "publish": true } or { "publish": false }.

Publishing runs the full validator against the stored map. If it finds any error, nothing is published and the response is 422:

{
  "error": "Map failed validation with 1 error: The map has no gold mines. Every faction's build order starts with gold, so no seat can do anything at all.",
  "validation": {
    "valid": false,
    "errorCount": 1,
    "warningCount": 0,
    "errors": [
      {
        "severity": "error",
        "code": "no-gold-mines",
        "path": "$",
        "message": "The map has no gold mines. Every faction's build order starts with gold, so no seat can do anything at all."
      }
    ],
    "warnings": []
  }
}

error quotes the first error's message. Each issue has the same shape as a validation issue. At most 50 errors and 50 warnings are listed; errorCount and warningCount count every issue in the validator's report, which itself stops at 500 of each (see Limits). On success the response is { "is_published": true, "validation": { … } }. See Publishing for the rules.

Rate: /api/maps/{id}/rate

  • GET (public): { avg_rating, rating_count, ratings: [{ rating, comment, created_at, username }] }. For an unpublished or hidden map it is a 404, except for the map's owner (and an admin, for a hidden map).
  • POST (sign-in): { "rating": 1-5, "comment"?: "…" }. You cannot rate your own map or an unpublished or hidden one. comment is a string or null; control characters other than line breaks are removed and the ends trimmed, and a comment still longer than 1000 characters is refused with 400 rather than cut short. Rating again replaces your earlier rating. Returns { avg_rating, rating_count }.

Report: POST /api/maps/{id}/report

Sign-in required. Body: { "reason": "offensive" | "spam" | "broken" | "copyright" | "cheating" | "other", "note"?: "…" }. A note is required for other (up to 1000 characters).

  • 201 { reported: true, hidden, reportCount } on success.
  • 409 if you already reported this map; 400 for your own map; 404 for a map that is not published or already hidden.

A map with three open reports is hidden automatically until a moderator reviews it.

Validation: POST /api/validate

No sign-in, CORS open to any origin, 30 requests per minute per IP address, request bodies up to 5 MB. Send a map, trigger script, data override set or .sosmod; get back { kind, label, valid, errors, warnings, engineVersion, formatVersion }. A failed validation is still HTTP 200, and so is content that is not JSON.

Full request and response details, the command-line equivalent and a write-validate-fix loop are in Validation & Tooling.

Asset catalogue: GET /api/assets/catalog

A generated index of what the game ships, for picking ids to reference:

ParameterMeaning
categorymodels, sounds, units, buildings, spells or items. Anything else is 400. Absent means all.
raceFilters models, units and buildings
searchCase-insensitive substring of id, name or path

The same data is the file public/assets/catalog.json in the game's repository. How to use it, and its limits, are in the Asset Catalog.

Embedding the game

Any site may put the game in an iframe:

<iframe
  src="https://www.shardsofstone.com/play?embed=1&customMap=tower-defense-showcase"
  width="1280" height="720"
  allow="autoplay; fullscreen"
  style="border: none"
></iframe>

URL parameters

ParameterMeaning
embed=1Full-bleed layout without the site chrome. Required for framing and for the message bridge.
customMap=<name>A map bundled with the site, loaded from /maps/<name>.json
communityMap=<id>A published community map, loaded through /api/maps/<id>
mod=<id>A mod hosted on the site, loaded from /mods/<id>.sosmod (see Mods)

A customMap or communityMap link plays as a guest; no account is needed.

Framing and origins

  • Only /play?embed=1 can be framed. It is served with Content-Security-Policy: frame-ancestors *; every other page sends X-Frame-Options: DENY.
  • The game only listens to its direct parent, and only for message types starting with sos:.
  • Outbound messages other than sos:ready go to your origin, never to *. The game learns your origin from the first sos:* message you send; failing that, from location.ancestorOrigins or document.referrer. Until it knows your origin it sends only { "type": "sos:ready" }, and queues other messages (up to 64, oldest dropped). If your page sets referrerpolicy="no-referrer", reply to sos:ready with any sos: message so the queue is released. A type the game does not handle, such as sos:hello, is ignored but still counts.
  • A sandboxed iframe without allow-same-origin has an opaque origin that cannot be addressed, so it only ever receives sos:ready.
  • SharedArrayBuffer is available in the frame only if your page is itself cross-origin isolated and the iframe has allow="cross-origin-isolated". Without it the game still runs, using a slower path.

Messages to the game

Fields may be sent flat on the message, or nested under payload; both work.

typeFieldsEffect
sos:load-mapmapData (map JSON as a string or object), biome?, mod? (a .sosmod object), mapId?, mapName?Launch that map, with the mod applied. A mod that fails validation is refused, the map does not launch, and the game answers sos:mod-invalid. mapId and mapName (strings, up to 80 characters) label the map in the game's performance reports
sos:pausenonePause (single player only)
sos:resumenoneResume (single player only)

Messages from the game

Fields are always flat on the message object; there is no payload wrapper.

typeFieldsWhen
sos:readynoneThe page is listening
sos:mod-invaliderrors (strings)The mod sent with sos:load-map failed validation; that map was not launched
sos:match-starttickThe match starts
sos:trigger-eventname, unit, otherUnit, player, value, text, tick, sourceA trigger ran the Raise custom event action
sos:match-endwinner, tick, state ("VICTORY" or "DEFEAT")The match ends

sos:trigger-event is how a map talks to the page around it. A trigger that raises boss_defeated with a value of 1 arrives as:

{ "type": "sos:trigger-event", "name": "boss_defeated", "unit": 0, "otherUnit": 0, "player": 0, "value": 1, "text": "boss_defeated", "tick": 4200, "source": "internal" }

unit and otherUnit are entity ids from inside the match and mean nothing outside it.

A host page

const frame = document.getElementById('game');
const GAME_ORIGIN = 'https://www.shardsofstone.com';
 
window.addEventListener('message', (event) => {
  if (event.origin !== GAME_ORIGIN || event.source !== frame.contentWindow) return;
  const msg = event.data;
  switch (msg?.type) {
    case 'sos:ready':
      // Any sos:* message teaches the game our origin and releases queued messages.
      frame.contentWindow.postMessage({ type: 'sos:hello' }, GAME_ORIGIN);
      break;
    case 'sos:trigger-event':
      if (msg.name === 'boss_defeated') console.log('Boss down at tick', msg.tick);
      break;
    case 'sos:match-end':
      console.log('Match over:', msg.state, 'winner', msg.winner);
      break;
  }
});

Headless runs

The repository contains a headless harness, tools/ai_loop/HeadlessGame.ts, that runs a match in Node.js with no browser, no rendering and no sound. It is what the game's own balance and AI testing use. It is not an SDK: there is no npm package, the configuration is shaped around AI-versus-AI testing, and it changes whenever the team needs it to. The game's source is licensed under the GNU AGPL v3, which applies to anything you build from it.

To use it, clone the repository, install dependencies, and run a script with npx tsx. The smoke test runs one AI-versus-AI game:

npx tsx tools/ai_loop/test_headless.ts

A minimal script looks like that file:

import { HeadlessGame } from './HeadlessGame';
 
const game = new HeadlessGame({
  player0Race: 'dwarf',
  player1Race: 'goblin',
  player0Difficulty: 'hard',
  player1Difficulty: 'hard',
  mapSize: 'small',
  mapType: 'standard',
  mapSeed: 42,
  maxTicks: 12000,
  logInterval: 200,
});
await game.init();
const result = await game.run();
console.log(result.outcome, result.winnerPlayerId, result.duration.ticks);

run() resolves to a result with outcome (win, stalemate or error), winnerPlayerId, winnerTeamId, duration (ticks, gameMinutes, wallClockMs), per-seat statistics, a timeline, build orders and the AI's decision events.

Playing a custom map headlessly

tools/ai_loop/playMap.ts plays any map file in the real simulation and summarises what happened:

npx tsx tools/ai_loop/playMap.ts public/maps/hero-arena-showcase.json --seed 3
map      public/maps/hero-arena-showcase.json (seed 3)
seats    0:dwarf/t0 (human, idle) ✗  1:goblin/t1 ✗  2:ratmen/t2 ✗  3:lizardmen/t3
ended    defeat — winner none at tick 3711 (186s) [trigger]
verdict  victories [3] defeats [0,1,2]
fires    One-second Clock ×185, Bot Champions Think ×124, Scoring and Champion Death ×50, ...
caps     0
broken   0
deadRead 0 (reads of units that had already died — usually normal play)
errors   0
RESULT   OK
OptionDefaultMeaning
--ticks N24000 (20 minutes)Stop after this many ticks if the match has not ended
--seed S1Random seed; the same seed gives the same match
--jsonoffPrint the same summary as JSON

The map loads the way /play?customMap= loads it, with no bonus resources and no instant build. Human seats stay idle; computer seats are driven by the map's triggers, and by the AI unless rules.disableAi is set. A map with no roster runs as AI against AI. The run stops when the match ends or at the tick limit; "winner none" means the human seat lost.

Exit codes: 0 clean; 1 if anything threw, a trigger hit a cap, a trigger read a broken reference (an undeclared variable, an unknown id) or an event cascade was cut short; 2 for bad usage or a map that will not load. That makes it a useful second check after the validator: validation proves the map is well formed, and a clean headless run proves its triggers run without breaking.

For scripted play (issuing orders for the human seat), use HeadlessGame directly: run the map through normalizeMapJsonForPlay (from src/components/mapEditorGameFormat.ts), pass it as customMapJson with a mapSeed, then drive it with step() and issueCommand(...), and read getTriggerOutcomes(), getTriggerLogs() and getGameOver(). tools/ai_loop/playMap.ts and tools/ai_loop/showcaseMaps.realEngine.test.ts are worked examples.

Headless runs honour the map's roster and starting resources, rules.disableAi, noStartingUnits, standardVictory and allowedBuildings, data overrides, triggers and cutscene lengths. Not supported headlessly: anything visual (camera, interface), scripting human seats from the command line, custom sounds, pre-placed heroes, mod packages, alternate victory presets in rules.victory, and fog rules (the map is fully revealed). A trigger's victory or defeat ends the match only for the local seat, or on any victory when no seat is human.

To check that a map is well formed, you do not need a headless run: use the validator, over HTTP or with tools/validate.ts; see Validation & Tooling.

Previous: Asset Catalog. Next: Engine Internals & Events.