Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 1 — Your Snake’s First Move

Before we build anything smart, we need a server that the game engine can actually talk to. BattleSnake is a web server: the game engine sends you an HTTP request every turn, and you send back a direction. That’s the whole contract.

Let’s start there.

Creating the project

We’ll use a Rust workspace with two crates:

  • snake-ml — the ML library (board encoding, network, training)
  • snake-server — the HTTP server that responds to the game engine

Create the directory structure and all files from scratch. No git required.

Root Cargo.toml (workspace definition):

[workspace]
resolver = "2"
members = [
  "snake-server",
  "snake-ml",
]

snake-ml/Cargo.toml:

[package]
name = "snake-ml"
version = "0.1.0"
edition = "2021"

[dependencies]
candle-core = "0.6"
candle-nn = "0.6"
# Pinned to avoid a rand version conflict with candle-core's internals.
half = "=2.4.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Version note. This tutorial targets Candle 0.6. Candle is a fast-moving library — the latest version is 0.10 as of June 2026, and the API has changed significantly between versions (tensor methods, training loop API, optimizer signatures). The code examples in this tutorial compile with 0.6. If you use a newer version, expect method name changes and signature differences. The concepts (tensor operations, forward passes, backpropagation, training loops) are the same across versions — it’s the API surface that shifts.

#![allow(unused)]
fn main() {
//! ML components for the Battlesnake AI.

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Point {
    pub x: i32,
    pub y: i32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snake {
    pub id: String,
    pub body: Vec<Point>,
    pub health: u32,
    pub head: Point,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Board {
    pub width: u32,
    pub height: u32,
    #[serde(default)]
    pub food: Vec<Point>,
    #[serde(default)]
    pub snakes: Vec<Snake>,
    #[serde(default)]
    pub hazards: Vec<Point>,
}
}

snake-server/Cargo.toml:

[package]
name = "snake-server"
version = "0.1.0"
edition = "2021"

[dependencies]
actix-web = "4"
log = "0.4"
env_logger = "0.11"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
snake-ml = { path = "../snake-ml" }

snake-server/src/main.rs — we’ll build this out in the sections below.

To verify everything compiles:

cd battlesnake
cargo build

If it builds, the project structure is correct. Now let’s write the server.

What the game sends you

Every turn, the engine POSTs JSON to your /move endpoint. It looks like this:

{
  "game": {
    "id": "totally-unique-game-id",
    "ruleset": { "name": "standard", "version": "v1.1.15" },
    "timeout": 500
  },
  "turn": 14,
  "board": {
    "height": 11,
    "width": 11,
    "food":    [{ "x": 5, "y": 5 }],
    "hazards": [{ "x": 3, "y": 2 }],
    "snakes":  [
      {
        "id": "snake-508e96ac-94ad-11ea-bb37",
        "name": "My Snake",
        "health": 54,
        "body": [{ "x": 0, "y": 0 }, { "x": 1, "y": 0 }, { "x": 2, "y": 0 }],
        "head": { "x": 0, "y": 0 },
        "length": 3
      },
      {
        "id": "snake-b67f4906-94ae-11ea-bb37",
        "name": "Enemy",
        "health": 16,
        "body": [{ "x": 5, "y": 4 }, { "x": 5, "y": 3 }, { "x": 6, "y": 3 }, { "x": 6, "y": 2 }],
        "head": { "x": 5, "y": 4 },
        "length": 4
      }
    ]
  },
  "you": {
    "id": "snake-508e96ac-94ad-11ea-bb37",
    "name": "My Snake",
    "health": 54,
    "body": [{ "x": 0, "y": 0 }, { "x": 1, "y": 0 }, { "x": 2, "y": 0 }],
    "head": { "x": 0, "y": 0 },
    "length": 3
  }
}

That’s a lot. The important parts:

  • board — the whole game state. width and height are the board dimensions (usually 11×11, but can be larger). food is where the food pieces are. snakes is every snake in the game, including yours.
  • you — your snake specifically. It has a head (the front square), a body (all the squares), and health (goes down every turn unless you eat).
  • turn — what turn the game is on. Starts at 0.

The game runs on a 0-indexed grid. (0, 0) is the bottom-left corner. (10, 10) would be the top-right on an 11×11 board. The x-axis goes right; the y-axis goes up. This is a standard Cartesian coordinate system, not screen coordinates — “up” adds to y because y increases upward. When you move up, your snake’s head goes from (3, 2) to (3, 3). When you move down, it goes from (3, 2) to (3, 1).

What you send back

Simple:

{ "move": "up" }

The move is one of "up", "down", "left", or "right". That’s the entire response contract.

A minimal server

We’ll use Actix Web — it’s lightweight and straightforward. We already added it to snake-server/Cargo.toml above.

Here’s a working server that always moves up:

use actix_web::{web, App, HttpServer, HttpResponse};
use serde::{Deserialize, Serialize};
use snake_ml::{Board, Point, Snake};

// The request body the game sends you every turn
#[derive(Deserialize)]
struct MoveRequest {
    board: Board,
    you: Snake,
}

// What you send back — the direction
#[derive(Serialize)]
struct MoveResponse {
    #[serde(rename = "move")]
    direction: String,
}

// The POST /move endpoint
async fn move_handler(req: web::Json<MoveRequest>) -> HttpResponse {
    let _my_head = &req.you.head;

    // For now, always move up. The snake will walk into the wall
    // eventually, but we have a working server.
    let direction = "up";

    HttpResponse::Ok().json(MoveResponse {
        direction: direction.to_string(),
    })
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/move", web::post().to(move_handler))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

The server imports Board, Point, and Snake from the snake-ml crate we set up above — that’s the whole point of the workspace. Those types are shared between the server and the ML library. The server-specific types (MoveRequest, MoveResponse) stay here because they’re HTTP concerns, not game logic.

Run it:

cargo run

You now have a server on port 8080. The Battlesnake engine can call it if it’s publicly reachable.

For local development, install the official Battlesnake rules engine and run games against your local server without any tunnel or public URL:

# Start your server in one terminal
cargo run -p snake-server

# Run a local game in another terminal
snake game create --width 11 --height 11 --seed 42 \
  http://localhost:8080 \
  http://localhost:9090

The rules engine is also what Parts 6–7 use for the self-play training loop, so installing it early means you’re ready when we get there.

Either way: that’s a working server. Not a good one, but a working one.

The game loop

Here’s what the full game loop looks like across your snake’s lifetime:

  1. Engine starts the game — POST to /start with game info. You can acknowledge this; we don’t need to do much here.
  2. Each turn, engine calls /move — you receive the board state, decide on a direction, send it back. The engine moves the snakes, spawns food, checks for collisions.
  3. Engine ends the game — POST to /end. Clean up if you need to.

/end receives the same JSON as /move. /start is different — it fires once at the beginning of a game and sends only the basics: board size, your snake ID, and the timeout. It looks like this:

{
  "gameId": "a3w4d2-6f91-11ef-bc0f-9fe5aa27e6d3",
  "width": 11,
  "height": 11,
  "snakeId": "snake-508e96ac-94ad-11ea-bb37",
  "snakeName": "My Snake",
  "timeout": 500
}

The response for /start and /end is an empty {}. Return it and move on.

For this tutorial we only need /move, but the arena engine requires all three endpoints. Here’s how to wire them up:

#![allow(unused)]
fn main() {
// A minimal type for what /start sends
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct StartRequest {
    game_id: String,
    width: u32,
    height: u32,
    #[serde(default)]
    timeout: u32,
}

async fn start_handler(_req: web::Json<StartRequest>) -> HttpResponse {
    HttpResponse::Ok().json(serde_json::json!({}))
}

// /end uses the same MoveRequest as /move
async fn end_handler(_req: web::Json<MoveRequest>) -> HttpResponse {
    HttpResponse::Ok().json(serde_json::json!({}))
}

// In main():
App::new()
    .route("/start", web::post().to(start_handler))
    .route("/move", web::post().to(move_handler))
    .route("/end", web::post().to(end_handler))
}

Making the snake a little smarter

Moving straight up is a fast way to die. Let’s at least look at where the head is and pick a direction that doesn’t immediately hit a wall:

#![allow(unused)]
fn main() {
async fn move_handler(req: web::Json<MoveRequest>) -> HttpResponse {
    let head = &req.you.head;
    let width = req.board.width as i32;
    let height = req.board.height as i32;

    // Check which directions are safe
    let mut safe_moves = Vec::new();

    if head.y + 1 < height {
        safe_moves.push("up");
    }
    if head.y - 1 >= 0 {
        safe_moves.push("down");
    }
    if head.x - 1 >= 0 {
        safe_moves.push("left");
    }
    if head.x + 1 < width {
        safe_moves.push("right");
    }

    // Pick the first safe direction (up, then down, left, right).
    // This isn't random — it's deterministic and predictable. Good enough
    // to avoid immediate walls, bad enough to motivate the neural network.
    let direction = safe_moves
        .into_iter()
        .next()
        .unwrap_or("up");

    HttpResponse::Ok().json(MoveResponse {
        direction: direction.to_string(),
    })
}
}

This snake will wander toward a wall, then wander along it. It’s better than always moving up, but it has no strategy. It doesn’t know what food is, doesn’t avoid other snakes, doesn’t plan ahead.

That’s what the neural network is for. Before we get there, we need to figure out how to represent the board state in a way the network can understand — which is what the next part is about.

Next: Part 2 — The Board as Numbers