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]
burn = { version = "0.21", features = ["std", "autodiff", "cpu"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
snake-ml/src/lib.rs (minimal stub — we’ll fill this in Part 2):
#![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.widthandheightare the board dimensions (usually 11×11, but can be larger).foodis where the food pieces are.snakesis every snake in the game, including yours.you— your snake specifically. It has ahead(the front square), abody(all the squares), andhealth(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:
- Engine starts the game — POST to
/startwith game info. You can acknowledge this; we don’t need to do much here. - 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. - 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 > 0 {
safe_moves.push("down");
}
if head.x > 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 shows how to convert the game state into a tensor the network can read.