Doom with recorded play, 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. We pointed the same integration at a Canonopy decision model by changing the URL, the key and model, and built that model through this API with an ordinary API key, from recorded play. This cookbook shows the results, then every step of the build.
No recorded play? Build it from the game's Jev request alone: built automatically with zero data, it still beat Jev at the same decision rate, 45 kills to 39 and 2 deaths to 5. See Doom with zero data. Recorded play, as below, took it further.
The result
The same 13 live games for every side, on the same map (Freedoom MAP01), with the same state, the same four questions and the same controller:
Canonopy doom-4 | Jev | |
|---|---|---|
| Kills (13 games) | 56 | 34 |
| Deaths | 0 | 6 |
| Map squares explored, all games | 282 | 114 |
| Seconds spent walking into walls | 8.4 | 29.2 |
| Median answer | 10 ms | 125 ms |
| Games won | 12 of 13 | 1 |
doom-4 learned from 18,237 recorded moments of play. It took four rounds to get here, each fixing one habit we saw by watching the games: chasing monsters it couldn't reach, walking into walls, and stopping to stare after a fight. Every round was better recordings and a retrain of about 20 seconds.
Read these before quoting the numbers
- Our model was served from the same machine as the game; Jev's answers came over the internet. So the times aren't like for like; running the model next to your game is an option Jev doesn't have.
- Thirteen games is a small sample, and Jev's answers vary from run to run: across all our runs on these games it had between 26 and 34 kills and 6 to 8 deaths.
- Kills stop near 4 on this map: the other monsters sit behind doors that neither side got through in the time limits.
- A wall bump is a moment when a side holds forward but hardly moves, with no monster close. Most of Jev's came from walking toward pickups it couldn't reach.
- The side-by-side viewer is a private demo.
How it works
The game sends the same request it sent to Jev: its structured state and four Choice questions. Nothing in the integration changed but the endpoint, the key and model:
- POST https://api.typesafe.ai/v1/systemone "model": "jev-latest"
+ POST https://api.canonopylabs.com/v1/systemone "model": "doom-2@latest"Behind that URL is a normal decision model:
- four questions, exactly as the game asks them:
movement(6 options),view(3),trigger(2) andinteraction(2); triggeris the decision question, so the firing rules are hard rules: it can't fire at nothing, off target, out of range or with no ammunition;- the state fields it reads from the game's state, by path: health, ammunition, what's in sight, how lined up and how far the nearest monster is, where it has been, and the nearest things around it.
Build it
1. Create the domain
Save this as doom.json. The options are the integration's own, word for word. (Here the domain is doom; ours in the results is doom-2, the second model: see the lesson.)
{
"name": "doom",
"description": "Doom player controls for each tick",
"questions": {
"movement": { "type": "choice", "instructions": "Choose navigation for this tick.",
"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": "Choose where to look.",
"criteria": { "KEEP_HEADING": "Keep the current heading.", "SCAN": "Turn to inspect the environment.",
"FACE_ENEMY": "Center the nearest visible enemy." } },
"trigger": { "type": "choice", "instructions": "Choose whether to fire.",
"criteria": { "HOLD_FIRE": "Do not fire.", "FIRE": "Press the weapon trigger." } },
"interaction": { "type": "choice", "instructions": "Choose whether to use a nearby line.",
"criteria": { "NO_USE": "Do not activate anything.", "USE": "Activate a nearby door or switch." } }
},
"decision_question": "trigger",
"state_schema": { "fields": [
{ "name": "health", "type": "number", "path": "player.health" },
{ "name": "armor", "type": "number", "path": "player.armor" },
{ "name": "weapon", "type": "category", "path": "player.weapon",
"values": ["fist", "pistol", "shotgun", "chaingun", "rocket_launcher", "plasma", "bfg", "chainsaw", "super_shotgun"] },
{ "name": "bullets", "type": "number", "path": "player.ammo.bullets" },
{ "name": "shells", "type": "number", "path": "player.ammo.shells" },
{ "name": "under_fire", "type": "number", "path": "player.under_fire", "description": "yes/no" },
{ "name": "stuck", "type": "number", "path": "player.motion.stuck", "description": "yes/no" },
{ "name": "enemy_detected", "type": "number", "path": "combat.enemy_detected", "description": "yes/no" },
{ "name": "enemies_in_sight", "type": "number", "path": "combat.visible_enemy_count" },
{ "name": "enemy_distance", "type": "number", "path": "combat.nearest_visible_enemy.distance" },
{ "name": "enemy_angle", "type": "number", "path": "combat.nearest_visible_enemy.relative_angle" },
{ "name": "enemy_aligned", "type": "number", "path": "combat.nearest_visible_enemy.aligned", "description": "yes/no" },
{ "name": "enemy_in_range", "type": "number", "path": "combat.nearest_visible_enemy.in_fighting_range", "description": "yes/no" },
{ "name": "cell_visits", "type": "number", "path": "exploration.current_cell_visits" },
{ "name": "novelty", "type": "number", "path": "exploration.novelty" },
{ "name": "previous_action", "type": "category", "path": "history.previous_action",
"values": ["EXPLORE_WORLD", "MOVE_TO_ENEMY", "RETREAT_FROM_ENEMY", "FACE_ENEMY", "FIRE", "COLLECT_NEAREST_PICKUP", "USE_NEAREST_LINE", "IDLE"] },
{ "name": "pickup_1_distance", "type": "number", "path": "visible_pickups[0].distance_units" },
{ "name": "thing_1_distance", "type": "number", "path": "world.entities[0].distance" },
{ "name": "thing_1_angle", "type": "number", "path": "world.entities[0].relative_angle" },
{ "name": "thing_1_is_enemy", "type": "number", "path": "world.entities[0].enemy", "description": "yes/no" },
{ "name": "thing_1_visible", "type": "number", "path": "world.entities[0].visible", "description": "yes/no" }
] }
}curl https://api.canonopylabs.com/v1/domains -H "Authorization: Bearer $CANONOPY_API_KEY" \
-H "Content-Type: application/json" -d @doom.jsonThat's a working start. doom-2 read 74 fields: these, the rest of the player's state (rockets, cells, recent damage, how far it moved), and the same five facts for each of the eight nearest things (world.entities[0] to world.entities[7]). Everything else in the state, like the long text description and the raw map lines, isn't read.
2. Say the firing rules in plain words
curl https://api.canonopylabs.com/v1/domains/doom/agent -H "Authorization: Bearer $CANONOPY_API_KEY" \
-H "Content-Type: application/json" -d '{"message": "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."}'These are the integration's own constraint ("fire only when a living enemy is visible, aligned, in range, and ammunition is appropriate"), written as rules. The reply:
Set up 2 actions, 21 state fields and 7 rules that can't be broken. Next, train it (looking at the example cases is optional, any time).Hard rules apply to the decision question
We also asked for "Never MOVE_TO_ENEMY when enemy_detected is false". The agent refused it: 'MOVE_TO_ENEMY' isn't one of your actions (HOLD_FIRE, FIRE). Hard rules apply to the domain's decision question, here trigger. The other three questions are shaped by your cases instead (step 3).
3. Upload recorded play
Your cases are moments from recorded play, each with the right answer to all four questions. They can come from a scripted bot, a designer playing, or your strongest players. One JSON line per moment: the state the game sent, and the answers.
{"state": {"player": {"health": 100, "...": "..."}, "combat": {"...": "..."}, "...": "..."},
"answers": {"movement": "MOVE_TO_ENEMY", "view": "FACE_ENEMY", "trigger": "FIRE", "interaction": "NO_USE"}}curl https://api.canonopylabs.com/v1/domains/doom/data -H "Authorization: Bearer $CANONOPY_API_KEY" \
-F file=@recorded_play.jsonl{ "accepted": 6278, "rejected": 0, "total_cases": 6278,
"per_question": { "movement": 6278, "view": 6278, "trigger": 6278, "interaction": 6278 }, "problems": [] }4. Train
curl -X POST https://api.canonopylabs.com/v1/domains/doom/train -H "Authorization: Bearer $CANONOPY_API_KEY"doom-2 took 22 seconds. On its held-back moments:
movement trained 97.2% on 675 held-back
view trained 98.4% on 675 held-back
trigger trained 99.9% on 675 held-back
interaction trained 100.0% on 675 held-back
rules: 0 violationsThe report's to-do list was all boundaries, like SCAN vs KEEP_HEADING and EXPLORE_WORLD vs COLLECT_NEAREST_PICKUP, and "more cases" items. See Training and reports.
Optional, any time: see how it decides on 20 examples. Since you uploaded first, most of them are real moments from your recordings.
curl https://api.canonopylabs.com/v1/domains/doom/examples -H "Authorization: Bearer $CANONOPY_API_KEY"If one is wrong, correct it with POST /v1/domains/doom/signoff and "retrain": true, as in Training and reports. For doom-2 all 20 came from the recordings and followed the firing rule, so there was nothing to correct.
5. Decide
The game sends its whole state and all four questions, with their full instructions, in Jev's format. A short request you can try, asking only trigger, as request.json:
{ "model": "doom@latest",
"state": { "player": { "health": 100, "armor": 100, "weapon": "pistol", "ammo": { "bullets": 50, "shells": 32 },
"under_fire": false, "motion": { "stuck": false } },
"combat": { "enemy_detected": true, "visible_enemy_count": 3,
"nearest_visible_enemy": { "distance": 232, "relative_angle": 0, "aligned": true, "in_fighting_range": true } },
"exploration": { "current_cell_visits": 2, "novelty": 0.5 },
"history": { "previous_action": "EXPLORE_WORLD" } },
"questions": { "trigger": { "type": "choice", "instructions": "Choose whether to fire.",
"criteria": { "HOLD_FIRE": "Do not fire.", "FIRE": "Press the weapon trigger." } } } }Send the whole state in play: a field left out counts as missing, and the model is less sure without it.
curl https://api.canonopylabs.com/v1/systemone -H "Authorization: Bearer $CANONOPY_API_KEY" \
-H "Content-Type: application/json" -d @request.jsonThe game's full request gets an answer to each question:
{ "model": "doom@1",
"answers": {
"movement": { "type": "choice", "choice": "HOLD_POSITION", "confidence": 1.0, "source": "trained", "route": "act", "...": "..." },
"view": { "type": "choice", "choice": "FACE_ENEMY", "confidence": 1.0, "source": "trained", "route": "act", "...": "..." },
"trigger": { "type": "choice", "choice": "FIRE", "confidence": 1.0, "source": "trained", "route": "act", "...": "..." },
"interaction": { "type": "choice", "choice": "NO_USE", "confidence": 1.0, "source": "trained", "route": "act", "...": "..." } },
"decision": { "question": "trigger", "action": "FIRE", "confidence": 1.0,
"blocked_by_rules": [], "blocked_actions": [], "route": "act" } }The questions a request sends only need the same options as the domain's; their instructions can be as long as the game likes. Each answer keeps Jev's fields, so the controller that played Jev's answers plays these unchanged.
To run it inside the game instead, download it and use canonopy-runtime: see Running it yourself.
With the CLI
The same steps with the canonopy command:
canonopy describe doom - < rules.txt # the sentences from step 2
canonopy upload doom recorded_play.jsonl
canonopy train doom
canonopy report doom
canonopy examples doom # optional: how it decides on 20 examples
canonopy decide doom@latest --state @state.json --questions @questions.json # the game's state and questionsThe lesson: it learns what your cases show
The first model, doom@1, never died, but it explored less than Jev: 28 kills and 8.4 squares over the same nine games. After the opening fight it kept answering MOVE_TO_ENEMY toward a monster hidden behind a wall, and walked into the wall.
That habit was in its recordings. In more than half of the recorded moments with no monster in sight, the recorded answer was to head for the nearest monster anyway. The model learned exactly what it was shown.
The fix was better recordings, not a different API. The new recordings showed:
- with nothing in sight, never head for a hidden monster: pick up what's close, otherwise explore;
- lingering in one spot counts as stuck: look around and move on;
- while exploring, press use, so doors open;
- in a fight, back off earlier when outnumbered.
A retrain takes about a minute. Replace the recordings and train the same model:
curl "https://api.canonopylabs.com/v1/domains/doom/data?mode=replace" -H "Authorization: Bearer $CANONOPY_API_KEY" \
-F file=@better_play.jsonl
curl -X POST https://api.canonopylabs.com/v1/domains/doom/train -H "Authorization: Bearer $CANONOPY_API_KEY"Or canonopy upload doom better_play.jsonl --replace, then canonopy train doom. The old recordings are deleted, and doom@2 learns from the new ones only. The game keeps calling doom@latest, doom@1 stays available to roll back to, and the report's cases says how many earlier cases were removed. The comparison with doom@1 uses the new recordings' held-back moments, so recordings that fix a bad habit can win it. To drop only some recordings, list the uploads (canonopy uploads doom) and delete one; see Replace or remove cases.
We built our second round before cases could be replaced, as a new domain, doom-2, with the same questions and rules. It kept doom@1's zero deaths, explored about 2.5 times as much, and beat Jev on kills, deaths and exploration. A third round, doom-3, taught it to turn before walls: wall bumps fell from 16 seconds to 3 over 13 games, with no deaths. But it stopped to stare after fights, so a fourth round, doom-4, recorded finishing the fight and then sweeping the level, turning while walking: it explored the most of any version, never died, and won 12 of 13 against Jev.
A bad habit in the recordings becomes a bad habit in play
Watch the games, find the moment it goes wrong, and look at what your recordings say to do there. Record better cases for that moment and train again. The report's to-do list points at the boundaries where more cases help most.