A Snake bot from recorded moves
Jev's own demo repository has a Snake game that asks Jev for every move. Its request holds the board and a Choice question over the legal moves, with facts about each one: does it eat the food, how far is the food after it, how much room is left, is it a dead end, can the snake still follow its tail out.
This cookbook plays the same game with your own decision model, learned from recorded moves: the game state in, a Choice question over the moves out. It's a normal decision model, so you can call it like Jev or download it and run it inside the game loop.
No recorded moves? Build it from the game's Jev request alone: built automatically with zero data, it ate 42.0 to Jev's 40.0 at equal steps and never died. See Snake with zero data.
The results
Built through the API exactly as below, from 3,000 moves recorded from a simple scripted player, it trained in a few seconds. On the demo's 20 test games, each stopped at the step where Jev's game on that seed stopped:
| Your model | Jev | |
|---|---|---|
| Food eaten, per game | 46.1 | 40.0 |
| Games that ended in a crash | 3 of 20 | 1 of 20 |
| Time per move | 0.08 ms, downloaded, in the game's process | 131 ms, median round trip |
It eats faster than Jev but crashes more often. It chose the recorded player's move 99% of the time on held-back moves, and the player itself crashed in none of these games; the few moves it gets wrong come in tight spots, where one wrong move ends the game. The fix is more recorded moves from those spots: play the model, find where it goes wrong, record the right move there, and train again (see the lesson in the Doom cookbook).
In an earlier test of ours, a 6.5 KB model out-ate Jev at equal steps (49–55 food against 40), deciding in well under a millisecond on a laptop against Jev's 131 ms round trip. That model was trained offline in our own test, not through this API.
Read these before quoting the numbers
- 20 games on the demo's 16×16 board. Jev played the demo's default
safestrategy; its games were stopped after about 700 moves to cap what the test spent, so they're compared over the same number of steps. - A second build from the same moves ate 44.9 and crashed in 5 of 20: each build holds back a different tenth of your cases, so results vary a little.
- Jev's numbers are from our earlier run of Jev on the demo's own code. Ours played the same engine and the same 20 games.
- Our 0.08 ms is the downloaded model on a laptop CPU, facts already computed; Jev's 131 ms is a round trip over the internet.
Build it
1. Send the state, plus each move's facts
Send the state you already send Jev. Add each legal move's facts under moves, as numbers and yes/no values; a move that isn't legal has no entry. Fields the model doesn't read, like the board, are ignored.
{ "heading": "right", "snakeLength": 3, "head": { "row": 8, "col": 8 }, "food": { "row": 0, "col": 13 },
"moves": {
"up": { "eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true },
"right": { "eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true },
"down": { "eats": false, "food_distance": 14, "room": 253, "dead_end": false, "tail_reachable": true } } }2. Create the domain
One Choice question, move, which is also the decision question, so your rules apply to it. The fields read each move's facts by path. The four rules block a move that has no facts, so as long as you send facts only for legal moves, the snake never moves into a wall or itself, whatever the model thinks.
Save this as snake.json:
{
"name": "snake",
"description": "Which way the snake moves next",
"questions": {
"move": { "type": "choice", "instructions": "Which way should the snake move next?",
"criteria": { "up": "move up", "down": "move down", "left": "move left", "right": "move right" } }
},
"decision_question": "move",
"rules": [
{ "id": "up-only-when-safe", "description": "Never move up into a wall or the snake",
"when": [{ "field": "up_room", "op": "is_missing" }], "block": ["up"] },
{ "id": "down-only-when-safe", "description": "Never move down into a wall or the snake",
"when": [{ "field": "down_room", "op": "is_missing" }], "block": ["down"] },
{ "id": "left-only-when-safe", "description": "Never move left into a wall or the snake",
"when": [{ "field": "left_room", "op": "is_missing" }], "block": ["left"] },
{ "id": "right-only-when-safe", "description": "Never move right into a wall or the snake",
"when": [{ "field": "right_room", "op": "is_missing" }], "block": ["right"] }
],
"state_schema": { "fields": [
{ "name": "length", "type": "number", "path": "snakeLength" },
{ "name": "up_eats", "type": "number", "path": "moves.up.eats", "description": "yes/no" },
{ "name": "up_food_distance", "type": "number", "path": "moves.up.food_distance" },
{ "name": "up_room", "type": "number", "path": "moves.up.room" },
{ "name": "up_dead_end", "type": "number", "path": "moves.up.dead_end", "description": "yes/no" },
{ "name": "up_tail_reachable", "type": "number", "path": "moves.up.tail_reachable", "description": "yes/no" },
{ "name": "down_eats", "type": "number", "path": "moves.down.eats", "description": "yes/no" },
{ "name": "down_food_distance", "type": "number", "path": "moves.down.food_distance" },
{ "name": "down_room", "type": "number", "path": "moves.down.room" },
{ "name": "down_dead_end", "type": "number", "path": "moves.down.dead_end", "description": "yes/no" },
{ "name": "down_tail_reachable", "type": "number", "path": "moves.down.tail_reachable", "description": "yes/no" },
{ "name": "left_eats", "type": "number", "path": "moves.left.eats", "description": "yes/no" },
{ "name": "left_food_distance", "type": "number", "path": "moves.left.food_distance" },
{ "name": "left_room", "type": "number", "path": "moves.left.room" },
{ "name": "left_dead_end", "type": "number", "path": "moves.left.dead_end", "description": "yes/no" },
{ "name": "left_tail_reachable", "type": "number", "path": "moves.left.tail_reachable", "description": "yes/no" },
{ "name": "right_eats", "type": "number", "path": "moves.right.eats", "description": "yes/no" },
{ "name": "right_food_distance", "type": "number", "path": "moves.right.food_distance" },
{ "name": "right_room", "type": "number", "path": "moves.right.room" },
{ "name": "right_dead_end", "type": "number", "path": "moves.right.dead_end", "description": "yes/no" },
{ "name": "right_tail_reachable", "type": "number", "path": "moves.right.tail_reachable", "description": "yes/no" }
] }
}curl https://api.canonopylabs.com/v1/domains -H "Authorization: Bearer $CANONOPY_API_KEY" \
-H "Content-Type: application/json" -d @snake.json3. Upload recorded moves
Your cases are moments from recorded play, each with the move that was made: from a scripted player, a designer, or your best players. One JSON line per move:
{"state": {"heading": "right", "snakeLength": 3, "moves": {"up": {"eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true}, "...": "..."}}, "answers": {"move": "up"}}curl https://api.canonopylabs.com/v1/domains/snake/data -H "Authorization: Bearer $CANONOPY_API_KEY" \
-F file=@recorded_moves.jsonl{ "accepted": 3000, "rejected": 0, "total_cases": 3000, "per_question": { "move": 3000 }, "problems": [] }4. Train
curl -X POST https://api.canonopylabs.com/v1/domains/snake/train -H "Authorization: Bearer $CANONOPY_API_KEY"Ours took a few seconds and chose the recorded move on 99% of its held-back moves. The report also counts how often each rule applied; violations are always 0. See Training and reports.
5. Ask for each move
Like Jev's demo, list only the legal moves as the question's options; the model picks among them.
curl https://api.canonopylabs.com/v1/decide -H "Authorization: Bearer $CANONOPY_API_KEY" \
-H "Content-Type: application/json" -d '{
"model": "snake@latest",
"state": { "heading": "right", "snakeLength": 3,
"moves": { "up": { "eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true },
"right": { "eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true },
"down": { "eats": false, "food_distance": 14, "room": 253, "dead_end": false, "tail_reachable": true } } },
"questions": { "move": { "type": "choice", "instructions": "Which way should the snake move next?",
"criteria": { "up": "move up", "right": "move right", "down": "move down" } } }
}'The move is decision.action. Leave out questions and it answers over all four directions; the rules still block the ones with no facts, and decision.blocked_by_rules lists them.
In Python, with the SDK:
from canonopy import Client, Choice
client = Client() # reads CANONOPY_API_KEY
def next_move(state):
legal = list(state["moves"])
res = client.decide(
model="snake@latest",
state=state,
questions={"move": Choice("Which way should the snake move next?",
{m: f"move {m}" for m in legal})},
)
return res.decision.action6. Run it inside the game loop
A move every few milliseconds is where a download pays off: no network, nothing per move.
curl -L -o snake.zip -H "Authorization: Bearer $CANONOPY_API_KEY" \
https://api.canonopylabs.com/v1/models/snake@latest/downloadfrom canonopy_runtime import load
bot = load("snake.zip")
def next_move(state):
options = {m: f"move {m}" for m in state["moves"]}
res = bot.decide({"state": state, "questions": {"move": {"type": "choice", "criteria": options}}})
return res["decision"]["action"]The model is about 0.2 MB and reads numbers only, so it needs no text reader: pip install https://canonopylabs.com/dl/canonopy_runtime-0.6.3-py3-none-any.whl. See Running it yourself.