Skip to content
Docs / Doom, zero data

Doom with zero data, head to head with Jev

A community Doom integration asks Jev four questions on every tick: how to move, where to look, whether to fire, and whether to open a door. This cookbook builds your own player from the request the game already sends Jev, with no recorded play, through POST /v1/build, then plays it against Jev.

The result

The model below was built by POST /v1/build from the request in step 1, with no recorded play and no hand-written help. The build took about 4 minutes; the model is 638 KB to download.

It played the same 13 games as Jev, on the same map (Freedoom MAP01), with the same state, the same four questions and the same controller. Both sides decided at the same rate: every 500 ms each side was sent the state and its answer was played on the next tick, whatever its answer time.

Against Jev's default style (its instructions say to preserve health, retreat early and avoid unnecessary fights):

13 gamesYour model, zero dataJev
Kills4539
Deaths25
Map squares explored186164
Seconds spent walking into walls59.727.3
Median answer4 ms144 ms
Games won7 of 136

Against Jev's aggressive style (fight, close distance, never retreat), the same games:

13 gamesYour model, zero dataJev
Kills4521
Deaths211
Map squares explored18699
Games won12 of 131

A game is won by staying alive, then by more kills, then by exploring more.

Read these before quoting the numbers

  • It's one build, and 13 games per style on one map is a small sample; a seed fixes the start, not the whole real-time game. An earlier build, made before two changes to the builder, lost: 17 kills and 13 deaths.
  • Jev's side is its recorded games from our run of live Jev at the same decision rate, on the same 13 seeds; ours played the same seeds at the same rate. Jev missed 9 of its 3,015 ticks (an answer not back in time is dropped and the last move repeated); ours missed none.
  • Our model was served from the same machine as the game; Jev's answers came over the internet. The 4 ms and 144 ms are each side's own answer time; the game waited 500 ms for both.
  • It walked into walls for twice as long as Jev's default style, mostly heading for pickups and doors the state doesn't say it can reach.
  • On fresh situations, its four answers agreed with its instructions and rules 95.7% (movement), 100% (view), 99.9% (trigger) and 100% (interaction) of the time.
  • For comparison, hand-built (not what a build gives you): a player we built by hand from the same inputs, with no recorded play, got 52 kills and 0 deaths against the default style; a player built from recorded play got 56 kills and 0 deaths. See Doom with recorded play.

Build it

1. Take the request the game sends Jev

Every tick the game sends Jev its whole structured state and four Choice questions. Their instructions hold the task, the playing style and the integration's constraints, and the options are the integration's own, word for word. Save one real request as doom-jev-request.json (the state is shortened here). The build above was made from exactly this request, plus the two optional parts after it:

json
{
  "model": "jev-latest",
  "state": {
    "player": { "health": 100, "armor": 100, "weapon": "pistol",
                "ammo": { "bullets": 50, "shells": 32, "rockets": 0, "cells": 0 },
                "recent_damage": 0, "under_fire": false, "x": -192, "y": -192, "angle": 0,
                "motion": { "distance": 0, "stuck": false, "total_distance": 0 } },
    "visible_enemies": [ { "id": "monster_1", "distance_units": 232, "health": 30, "attacking": true, "threat": "high", "…": "…" } ],
    "visible_pickups": [ { "id": "pickup_1", "type": "item", "distance_units": 208, "reachable": true, "…": "…" } ],
    "combat": { "enemy_detected": true, "visible_enemy_count": 3,
                "nearest_visible_enemy": { "distance": 232, "relative_angle": 0, "aligned": true, "in_fighting_range": true, "…": "…" } },
    "exploration": { "visited_cells": 1, "current_cell_visits": 2, "novelty": 0.5 },
    "history": { "previous_action": "EXPLORE_WORLD", "current_intent": "survive_level" },
    "world": { "entities": [ { "distance": 208, "relative_angle": 960960175, "visible": true, "enemy": false, "pickup": true, "…": "…" } ] }
  },
  "questions": {
    "movement": { "type": "choice",
      "instructions": { "task": "Choose navigation for this tick.",
        "policy": "Preserve health above everything else. Retreat early, use cover, collect health and armor, and avoid unnecessary fights.",
        "constraints": [ "Choose all four axes independently; they execute simultaneously.",
          "Approaching and facing do not imply firing. Fire only when a living enemy is visible, aligned, in range, and ammunition is appropriate.",
          "Use the combat sensor, player condition, exploration memory, entities, and linedefs.",
          "If stuck, change movement or view; do not idle without a reason." ] },
      "criteria": { "HOLD_POSITION": "Do not translate.", "EXPLORE_WORLD": "Navigate toward under-visited space.",
                    "MOVE_TO_ENEMY": "Path toward the nearest living enemy and stop at fighting distance.",
                    "RETREAT_FROM_ENEMY": "Create distance from the nearest enemy.",
                    "COLLECT_NEAREST_PICKUP": "Path toward the nearest useful pickup.",
                    "MOVE_TO_USE": "Path toward a usable door or switch." } },
    "view": { "type": "choice", "instructions": { "task": "Choose where to look.", "…": "the same policy and constraints" },
      "criteria": { "KEEP_HEADING": "Keep the current heading.", "SCAN": "Turn to inspect the environment.",
                    "FACE_ENEMY": "Center the nearest visible enemy." } },
    "trigger": { "type": "choice", "instructions": { "task": "Choose whether to fire.", "…": "the same policy and constraints" },
      "criteria": { "HOLD_FIRE": "Do not fire.", "FIRE": "Press the weapon trigger." } },
    "interaction": { "type": "choice", "instructions": { "task": "Choose whether to use a nearby line.", "…": "the same policy and constraints" },
      "criteria": { "NO_USE": "Do not activate anything.", "USE": "Activate a nearby door or switch." } }
  }
}

Send the real thing, not a shortened copy: the whole state as the game sends it, and every question with its full instructions.

Two optional parts made the build above better, and cost nothing to add:

  • examples: more real states. The build above had 16 more states the game really sent, from games on other seeds than the 13 test games, as a list in examples. States only, no answers.
  • fields: what the numbers mean. A note for the fields whose units aren't obvious, keyed by path:
json
"fields": {
  "player.angle": { "meaning": "the direction the player faces, as a Doom binary angle: 0 = east, 1,073,741,824 = north", "unit": "binary angle (2^32 per turn)", "range": [0, 4294967295] },
  "combat.nearest_visible_enemy.relative_angle": { "meaning": "direction from the player's facing to the nearest visible monster; positive = to the left; aligned when its size is at most combat.aim_tolerance", "unit": "binary angle (2^32 per turn)" },
  "combat.fighting_distance": { "meaning": "the distance within which a monster counts as in fighting range; always 384", "unit": "map units" },
  "player.recent_damage": { "meaning": "Doom's damage counter: goes up by the damage just taken; 0 = not hurt recently", "range": [0, 100] },
  "history.stuck_probability": { "meaning": "1 when the player has been trying to move but moved under 2 map units for 3 or more decisions in a row, else 0", "range": [0, 1] }
}

2. Build, with the firing rules in plain words

The integration's firing constraint, said as rules, so it can never be broken:

These are the rules the build above was given, word for word:

bash
curl https://api.canonopylabs.com/v1/build -H "Authorization: Bearer $CANONOPY_API_KEY" \
  -H "Content-Type: application/json" \
  -d "$(jq '. + {name: "doom", rules: "Never FIRE when enemy_detected is false. Never FIRE when enemy_aligned is false. Never FIRE when enemy_in_range is false. Never FIRE when weapon is pistol and bullets is 0. Never FIRE when weapon is chaingun and bullets is 0. Never FIRE when weapon is shotgun and shells is 0. Never FIRE when weapon is super_shotgun and shells is 0. FIRE when enemy_detected is true and enemy_aligned is true and enemy_in_range is true. Otherwise HOLD_FIRE."}' doom-jev-request.json)"

Or with the CLI: canonopy build doom-jev-request.json --name doom --rules "Never FIRE when enemy_detected is false. …" --wait.

Check understood in the answer:

  • fields: every value in the state, each with its path and type: the player's health, armour, weapon and ammunition, what's in sight and how lined up and far it is, where it has been, and the nearest things around it (lists of objects are read at their first 8 positions). not_read lists what's left out and why, like the ids.
  • decision_question: trigger, the question the rules are about. Your hard rules apply to it.
  • rules: every sentence, each with the field it was matched to (enemy_aligned became combat.nearest_visible_enemy.aligned). The "Never" sentences are enforced on every decision, whatever the model thinks; the rest your model follows in its answers. rules_not_understood is empty.

The other three questions follow their instructions (the task, the style and the constraints), which your model learns.

3. Wait for ready

There's nothing to do in between. It's built and checked against your instructions and rules on fresh situations; each of the four questions has to agree at least 95% of the time before it serves. canonopy build status doom shows where it is (--wait on the build returns when it's ready), and weakest shows where each question matches least. See the quality check.

4. See how it decides (optional)

Any time once it's ready: about 20 game situations, each with the answer to all four questions and why.

bash
canonopy examples doom
canonopy signoff doom --correct ex_04:movement=RETREAT_FROM_ENEMY --retrain   # only if one is wrong

Look for what you care about in a player: does it retreat when hurt, stop at fighting distance, look around when stuck? A correction with --retrain makes a new version that gives that answer.

5. Point the game at it

Nothing in the integration changes but the endpoint, the key and model:

diff
- POST https://api.typesafe.ai/v1/systemone      "model": "jev-latest"
+ POST https://api.canonopylabs.com/v1/systemone  "model": "doom@latest"

The game keeps sending its whole state and all four questions, and gets an answer to each, in Jev's shape:

json
{ "model": "doom@1",
  "answers": {
    "movement":    { "type": "choice", "choice": "HOLD_POSITION", "confidence": 0.98, "source": "trained", "route": "act", "…": "…" },
    "view":        { "type": "choice", "choice": "FACE_ENEMY", "confidence": 1.0, "source": "trained", "route": "act", "…": "…" },
    "trigger":     { "type": "choice", "choice": "FIRE", "confidence": 0.99, "source": "trained", "route": "act", "…": "…" },
    "interaction": { "type": "choice", "choice": "NO_USE", "confidence": 1.0, "source": "trained", "route": "act", "…": "…" } },
  "decision": { "question": "trigger", "action": "FIRE", "confidence": 0.99,
                "blocked_by_rules": [], "blocked_actions": [], "route": "act" } }

The controller that played Jev's answers plays these unchanged. To run it inside the game instead, next to the engine, download it and use canonopy-runtime: see Running it yourself.

Make it better

Watch it play. When it does something you don't want, you have three ways to fix it, and each retrain takes about a minute:

  • The rule is wrong or missing: say it in plain words, like "Never MOVE_TO_ENEMY when enemy_detected is false", with POST /v1/domains/doom/rules. It retrains straight away, and the build says what changed. See Change a rule with a preview.
  • A move was wrong: mark it wrong in the decision log with the right answers. See Mark a decision wrong.
  • You have recorded play: send it as history and build again under the same name. Moments of play then come first. See Doom with recorded play for what recordings did for this game.