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:
| Surface | Status |
|---|---|
| Community map, validation and asset catalogue endpoints | Public. 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
| Parameter | Meaning |
|---|---|
published=true | Public, published maps. No sign-in needed. |
mine=true | Your own maps, published or not. Requires sign-in. |
search | Substring match on the map name |
category | Filter by map type (all means no filter) |
sort | newest (default), rating or popular |
page | Page number, from 1 |
limit | Results 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
| Request | Notes |
|---|---|
POST /api/maps | Sign-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 a404, 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.commentis a string ornull; control characters other than line breaks are removed and the ends trimmed, and a comment still longer than 1000 characters is refused with400rather 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.409if you already reported this map;400for your own map;404for 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:
| Parameter | Meaning |
|---|---|
category | models, sounds, units, buildings, spells or items. Anything else is 400. Absent means all. |
race | Filters models, units and buildings |
search | Case-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
| Parameter | Meaning |
|---|---|
embed=1 | Full-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=1can be framed. It is served withContent-Security-Policy: frame-ancestors *; every other page sendsX-Frame-Options: DENY. - The game only listens to its direct parent, and only for message types
starting with
sos:. - Outbound messages other than
sos:readygo to your origin, never to*. The game learns your origin from the firstsos:*message you send; failing that, fromlocation.ancestorOriginsordocument.referrer. Until it knows your origin it sends only{ "type": "sos:ready" }, and queues other messages (up to 64, oldest dropped). If your page setsreferrerpolicy="no-referrer", reply tosos:readywith anysos:message so the queue is released. A type the game does not handle, such assos:hello, is ignored but still counts. - A sandboxed iframe without
allow-same-originhas an opaque origin that cannot be addressed, so it only ever receivessos: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.
type | Fields | Effect |
|---|---|---|
sos:load-map | mapData (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:pause | none | Pause (single player only) |
sos:resume | none | Resume (single player only) |
Messages from the game
Fields are always flat on the message object; there is no payload
wrapper.
type | Fields | When |
|---|---|---|
sos:ready | none | The page is listening |
sos:mod-invalid | errors (strings) | The mod sent with sos:load-map failed validation; that map was not launched |
sos:match-start | tick | The match starts |
sos:trigger-event | name, unit, otherUnit, player, value, text, tick, source | A trigger ran the Raise custom event action |
sos:match-end | winner, 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.tsA 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 3map 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| Option | Default | Meaning |
|---|---|---|
--ticks N | 24000 (20 minutes) | Stop after this many ticks if the match has not ended |
--seed S | 1 | Random seed; the same seed gives the same match |
--json | off | Print 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.