Skip to content
Docs / Snake, recorded moves

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 modelJev
Food eaten, per game46.140.0
Games that ended in a crash3 of 201 of 20
Time per move0.08 ms, downloaded, in the game's process131 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 safe strategy; 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.

json
{ "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:

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" }
  ] }
}
bash
curl https://api.canonopylabs.com/v1/domains -H "Authorization: Bearer $CANONOPY_API_KEY" \
  -H "Content-Type: application/json" -d @snake.json

3. 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:

json
{"state": {"heading": "right", "snakeLength": 3, "moves": {"up": {"eats": false, "food_distance": 12, "room": 253, "dead_end": false, "tail_reachable": true}, "...": "..."}}, "answers": {"move": "up"}}
bash
curl https://api.canonopylabs.com/v1/domains/snake/data -H "Authorization: Bearer $CANONOPY_API_KEY" \
  -F file=@recorded_moves.jsonl
json
{ "accepted": 3000, "rejected": 0, "total_cases": 3000, "per_question": { "move": 3000 }, "problems": [] }

4. Train

bash
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.

bash
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:

python
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.action

6. Run it inside the game loop

A move every few milliseconds is where a download pays off: no network, nothing per move.

bash
curl -L -o snake.zip -H "Authorization: Bearer $CANONOPY_API_KEY" \
  https://api.canonopylabs.com/v1/models/snake@latest/download
python
from 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.