A call carries a simulation model id, which says which simulator to run, and an API key, which says who is calling. It posts a simulation model spec and receives a playback.
https://simengine.net/v1
The /v1 is a version prefix, not decoration: a change to the contract that would
break existing callers becomes /v2 rather than altering /v1 underneath
them.
Every route takes the key except GET /v1/openapi.json, which is the
published contract itself and needs none. The key travels in one header:
X-Api-Key: <the API key>
A call about a model names it in a second header:
X-Sim-Id: <the simulation model id>
Keys are managed from the account page, under Packs, and a secret is shown once, on replacement.
/v1/openapi.json — an OpenAPI 3.1 description of the whole surface: every route, its parameters, its request body, every status it can answer with and the refusal codes it can carry. A client generator pointed at it, plus the published request schema, needs nothing else.
It is the published contract itself, assembled with its
schema files inlined rather than written for this page, and the serving component's own suite
holds the document's routes to exactly the routes the host answers. The refusal codes it names
are the ones GET /v1/codes serves.
A simulation model id is designed to sit in a public page — published, embedded, printed. Each caller runs it against their own API key and their own account, so an id in the open costs its author nothing. The API key is the other half: everything done with it is attributed to the account that holds it, and a stolen key cannot be told from a shared one. It belongs server-side. A leaked key is replaced from the account page, and the previous one stops working immediately.
The spec is the caller's — the host serves no menu of battles. The spec says which bodies deploy and where; the model says what they are.
The shape is universal: every model takes the same document, and only the unit ids in it belong to a particular one. The schema is published, and it is generated from the engine's own request types rather than written for this page — one authority, no copy to drift. Duration is not part of it; the model's own battle template declares it.
No SDK and no dependency: it is JSON over HTTPS, so a standard library is enough. One call — the battle runs and the answer is the playback.
import json, urllib.request
BASE = "https://simengine.net/v1"
API_KEY = "..." # your API key
MODEL = "..." # the simulation model id
def call(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("X-Api-Key", API_KEY)
req.add_header("X-Sim-Id", MODEL)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=180) as r:
return json.loads(r.read())
spec = {
"battle_id": 1, # a battle template of that model
"seed": 7,
"attackers": [
{"unit_id": 5002, "level": 3, "pos": {"x": -1, "z": 3}},
{"unit_id": 5009, "level": 3, "pos": {"x": 1, "z": 3}},
],
"defenders": [
{"unit_id": 5004, "level": 3, "pos": {"x": -1, "z": -3}},
{"unit_id": 5003, "level": 3, "pos": {"x": 1, "z": -3}},
],
}
playback = call("POST", "/battle/long", spec)
# A body on a row its camp's zone does not declare is refused, and the refusal arrives as a
# 200 carrying "fault" and "findings" where "result" would be. Read it, do not index past it.
if "result" not in playback:
print("no battle", playback.get("fault"), playback.get("findings"))
raise SystemExit(1)
result = playback["result"]
print("stop ", result["stop_reason"]) # an id of the engine's battle_stop_reason table
print("seconds", result["elapsed_seconds"])
The battle has separate attacker and defender lists, plus a template ID and seed. Each team must stay in its assigned rows. The battle ends when one team is defeated or time runs out. Check response for more details.