Build a Snake Game in Your Terminal
Build a playable Snake game in Rust using Ratatui and Crossterm.
Introduction
30 Second Summary
Snake is one of the simplest games ever made. You steer a growing line around a board, eat food, and try not to crash into yourself.
In this project, you will build a fully playable Snake game that runs entirely in your terminal. You'll use Rust and the Ratatui library to render the game with colored Unicode block characters and handle keyboard input in real time.
What You'll Build
Run a single command and your terminal transforms into a game board where a green snake glides across the screen under your control, chasing red food blocks and growing longer with every bite.
By the end of this project, you'll have:
- A snake that moves across the board in real time, responding to your arrow key input.
- Random food that spawns on the board and makes the snake grow when eaten, with a live score display updating in the sidebar.
- Collision detection that ends the game when you hit a wall or your own tail, complete with a Game Over screen and a restart option.
- An optional Secret Mission to push your skills even further.
Are there any prerequisites?
This project assumes you know basic Rust concepts like ownership, structs, enums, match expressions, and vectors. You should also have Rust and Cargo installed on your machine.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. Taking a moment to articulate the goal now gives every decision in the steps ahead a clear anchor.
Set Up Your Rust Project
Before you can render anything to the terminal, you need a Rust project with the right dependencies wired up.
In this step, you will create a new Rust project, add Ratatui and rand as dependencies, and run a hello world program that renders text in an alternate terminal screen.
In this step, get ready to:
- Create a new Rust project with Cargo.
- Add the Ratatui and rand dependencies.
- Run a hello world TUI that redraws continuously in its terminal window.
Create your Rust project
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project and move into it by running these commands:
cargo new snake-game
cd snake-game
What does this command do?
The cargo new command scaffolds a new Rust project. It creates a Cargo.toml file (your project manifest) and a src/main.rs file with a default "Hello, world!" program.
- Confirm the project was created by running:
ls
You should see Cargo.toml and src in the output.
Add the Ratatui and rand dependencies
Your snake game needs two external crates. Ratatui handles all the terminal UI rendering. rand generates random positions for the food.
- Add the ratatui dependency by running:
cargo add ratatui@0.30.2
- Add the rand dependency by running:
cargo add rand@0.10.2
Why pin specific versions?
Pinning to ratatui@0.30.2 and rand@0.10.2 ensures your project uses the exact same API this tutorial covers. Crate updates can introduce breaking changes, so pinning keeps your build predictable.
- Verify the dependencies were added by running:
cat Cargo.toml
You should see ratatui = "0.30.2" and rand = "0.10.2" listed under [dependencies].
Don't see the dependencies?
Make sure you ran the cargo add commands from inside the snake-game folder. Run pwd to check your current directory.
Help me troubleshoot my missing dependencies
Replace the default code with a Ratatui hello world
The default main.rs prints one line to the terminal. You need to replace it with a Ratatui program that takes over the screen and redraws the same widget in a continuous loop.
- Open the snake-game project in your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the snake-game folder on your Desktop, then click Open.
- In the Explorer sidebar, expand src and click main.rs to open it.
- Select all the existing code in main.rs and delete it. Then paste the following code:
fn main() -> std::io::Result<()> {
ratatui::run(|terminal| {
// Redraw the terminal UI continuously
loop {
let _ = terminal.draw(|frame| {
// Fill the current terminal area with the greeting
frame.render_widget(
ratatui::widgets::Paragraph::new("Hello World!"),
frame.area(),
)
});
}
})
}
What does this code do?
- ratatui::run() prepares the alternate terminal screen and passes a mutable terminal into the closure.
- The loop redraws the interface continuously. This first version does not handle keyboard input.
- terminal.draw() creates a frame before render_widget() fills its available area with the paragraph.
- let _ discards the result returned by each draw call.
- Save main.rs.
Before you run this, what do you think you will see in the terminal?
- Build and run the project by running:
cargo run
Your terminal switches to an alternate screen and displays Hello World! in the top-left corner. The program keeps redrawing because this first loop does not handle keyboard input.
Seeing a compilation error?
Make sure you deleted all of the original code in main.rs before pasting the new code. Leftover lines from the default template cause conflicts.
Check that your Cargo.toml lists ratatui = "0.30.2" under [dependencies]. Without this dependency, the ratatui::run call will not compile.
Help me debug my compilation error
✔️ Awesome, I've got everything!
Great. Double check you have saved main.rs.
ⓧ I'd like to double check the full code
Here is the full Cargo.toml:
[package]
name = "snake-game"
version = "0.1.0"
edition = "2024"
[dependencies]
ratatui = "0.30.2"
rand = "0.10.2"
Here is the full src/main.rs:
fn main() -> std::io::Result<()> {
ratatui::run(|terminal| {
// Redraw the terminal UI continuously
loop {
let _ = terminal.draw(|frame| {
// Fill the current terminal area with the greeting
frame.render_widget(
ratatui::widgets::Paragraph::new("Hello World!"),
frame.area(),
)
});
}
})
}
- Close the terminal window with its window controls after uploading your screenshot.
- Confirm that you want to terminate the running process if your terminal asks.
Your project is ready to go. Next up, you will draw the game board and bring the snake to life on screen.
Draw the Game Board
Your Ratatui hello world confirms the terminal UI toolkit is working. Now it's time to replace that placeholder with a real game board.
In this step, you'll split the terminal into two areas: a bordered game board on the left where the snake lives, and an info sidebar on the right showing the score and controls. You'll define the snake's data and render it as green Unicode block characters.
In this step, get ready to:
- Split the terminal into a game area and info sidebar using Ratatui's Layout system.
- Define the Game struct with the snake's starting position and direction.
- Render the snake as colored Unicode block characters on the game board.
Set up the game data and layout
The hello world was a single function that rendered one line of text. The real game needs much more structure: a data model for the snake, constants for the board size, and a layout that splits the terminal into two sections.
- Clear all the existing code in src/main.rs and replace it with the following imports, constants, and data types:
use std::collections::VecDeque;
use crossterm::event::{self, Event};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::DefaultTerminal;
// Board dimensions in terminal columns and rows
const BOARD_WIDTH: u16 = 40;
const BOARD_HEIGHT: u16 = 20;
// Possible movement directions for the snake
#[derive(Clone, Copy, PartialEq)]
enum Dir { Up, Down, Left, Right }
// Holds all game state in one place
struct Game {
snake: VecDeque<(i16, i16)>,
direction: Dir,
score: u32,
board_width: i16,
board_height: i16,
}
What does this code do?
- The crossterm imports come from Crossterm, the terminal backend that Ratatui includes by default. You'll use it to read keyboard events.
- BOARD_WIDTH and BOARD_HEIGHT set the game area to 40 columns wide and 20 rows tall.
- The Game struct stores the snake as a VecDeque of (x, y) positions. A VecDeque lets you efficiently push to the front and pop from the back, which is exactly how a snake moves.
- Add the following code below the Game struct. This creates the initial game state and sets up the main loop with a horizontal layout split:
impl Game {
// Create initial game state with a 3-segment snake in the center
fn new() -> Self {
// Subtract 2 for the border characters on each side
let bw = (BOARD_WIDTH - 2) as i16;
let bh = (BOARD_HEIGHT - 2) as i16;
let mut snake = VecDeque::new();
// Head is at center, body segments trail to the left
snake.push_back((bw / 2, bh / 2));
snake.push_back((bw / 2 - 1, bh / 2));
snake.push_back((bw / 2 - 2, bh / 2));
Game { snake, direction: Dir::Right, score: 0, board_width: bw, board_height: bh }
}
}
fn main() -> std::io::Result<()> {
ratatui::run(|terminal| run_game(terminal))
}
fn run_game(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
let game = Game::new();
loop {
terminal.draw(|frame| {
// Split terminal: 40-column game area left, sidebar right
let outer = Layout::horizontal([
Constraint::Length(BOARD_WIDTH),
Constraint::Min(20),
]).split(frame.area());
render_board(frame, &game, outer[0]);
render_sidebar(frame, &game, outer[1]);
})?;
// Exit on any key press (temporary)
if let Event::Key(_) = event::read()? { return Ok(()); }
}
}
What does this code do?
- Game::new() creates a 3-segment snake in the center of the board, facing right. The first element in the VecDeque is always the head.
- ratatui::run() handles all terminal setup and teardown automatically. It enters the alternate screen, runs your closure, and restores the terminal when done.
- Layout::horizontal() splits the terminal into a fixed 40-column game area on the left and a sidebar that takes the remaining space (minimum 20 columns) on the right.
- render_board and render_sidebar don't exist yet. You'll add them next.
Render the snake on the game board
The game board is a grid of characters. For each cell, you check whether the snake occupies that position. If it does, you render a green full block character (\u{2588}). If not, you render a space.
- Add the render_board function at the bottom of src/main.rs:
fn render_board(frame: &mut ratatui::Frame, game: &Game, area: Rect) {
let block = Block::bordered().title("Snake");
let inner = block.inner(area);
frame.render_widget(block, area);
// Build one Line per row of the board
let mut lines: Vec<Line> = Vec::new();
for y in 0..game.board_height {
let spans: Vec<Span> = (0..game.board_width).map(|x| {
let pos = (x, y);
if game.snake[0] == pos {
// Snake head in light green
Span::styled("\u{2588}", Style::default().fg(Color::LightGreen))
} else if game.snake.contains(&pos) {
// Snake body in darker green
Span::styled("\u{2588}", Style::default().fg(Color::Green))
} else {
Span::raw(" ")
}
}).collect();
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(lines), inner);
}
What does this code do?
- Block::bordered().title("Snake") creates a bordered box with the title "Snake" in the top-left corner.
- block.inner(area) calculates the drawable space inside the borders, so the grid doesn't overlap the border characters.
- The nested loop checks every (x, y) cell. The snake's head gets Color::LightGreen and body segments get Color::Green, so you can always spot the head.
- \u{2588} is the Unicode full block character. It fills an entire character cell with solid color, which is how you render game graphics in the terminal.
Add the info sidebar
The sidebar shows the player's current score and the controls. Each line is composed of styled text spans.
- Add the render_sidebar function below render_board in src/main.rs:
fn render_sidebar(frame: &mut ratatui::Frame, game: &Game, area: Rect) {
let block = Block::bordered().title("Info");
let inner = block.inner(area);
frame.render_widget(block, area);
// Build styled lines for score and control instructions
let text = vec![
Line::from(vec![Span::raw("Score: "), Span::styled(game.score.to_string(), Style::default().fg(Color::Yellow))]),
Line::raw(""),
Line::raw("Controls:"),
Line::raw("Arrow keys to move"),
Line::raw("'q' to quit"),
];
frame.render_widget(Paragraph::new(text), inner);
}
What does this code do?
- The sidebar follows the same bordered-block pattern as the game board.
- The score is styled with Color::Yellow so it stands out from the plain white text around it.
- Line::raw("") creates a blank line, which adds visual spacing between the score and the controls section.
- Save src/main.rs.
Before you run this, think about what you expect to see. You defined a 40-column game area and a sidebar. The snake is 3 segments long, sitting in the center. What will the terminal look like?
- Run the game by running this command:
cargo run
You should see a bordered "Snake" game area on the left with three green blocks in the center (that's your snake). On the right, a bordered "Info" sidebar displays "Score: 0" in yellow and the control instructions. Press any key to exit.
Seeing a compile error?
- If you see cannot find function errors for render_board or render_sidebar, make sure both functions are present in your file. The compiler needs all referenced functions to exist.
- If you see type mismatch errors, check that the function signatures match exactly, including frame: &mut ratatui::Frame and game: &Game.
- If the board looks stretched or misaligned, make sure BOARD_WIDTH is 40 and BOARD_HEIGHT is 20. Resize your terminal window wider if needed.
Help me debug a compile error in my Ratatui snake game board rendering
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/main.rs before moving on.
ⓧ I'd like to double check the full code
Your src/main.rs file should contain these sections in order. Scroll up and confirm each one is present.
- Imports: use std::collections::VecDeque plus six use crossterm/ratatui lines.
- Constants: BOARD_WIDTH = 40 and BOARD_HEIGHT = 20.
- Dir enum: four variants (Up, Down, Left, Right) with Clone, Copy, PartialEq derives.
- Game struct: five fields (snake, direction, score, board_width, board_height).
- impl Game: new() creates a 3-segment snake centered on the board.
- main(): calls ratatui::run() with run_game.
- run_game(): creates a Game, loops drawing the frame with Layout::horizontal, exits on any key press.
- render_board(): bordered "Snake" block, grid of lines with green block characters for snake positions.
- render_sidebar(): bordered "Info" block with yellow score, blank line, and control instructions.
Why build each frame from scratch?
Ratatui uses immediate-mode rendering. Every time the loop calls terminal.draw(), it rebuilds the entire screen from scratch. Nothing is remembered between frames. This makes state management simple: you update the Game struct, and the next draw call reflects the new state automatically.
Your game board is up and the snake is visible. Right now it just sits there, frozen in place. In the next step, you'll add a real game loop with timed movement and arrow key controls so the snake comes alive.
Make It Move
Right now your snake is frozen in place. A real game needs a game loop: a pattern where the program repeatedly updates game state, redraws the screen, and checks for player input on a fixed timer.
In this step, you will replace the blocking input handler with a timed loop that moves the snake on its own. You will also wire up arrow key controls and add boundary detection so the snake crashes when it hits a wall.
In this step, get ready to:
- Implement a timed game loop that moves the snake automatically.
- Handle keyboard input to steer the snake with arrow keys.
- Add boundary detection that ends the game when the snake hits a wall.
Implement the game loop
The current code uses event::read(), which blocks the entire program until the user presses a key. Nothing can move on its own. You need a non-blocking approach that checks for input periodically while advancing the game state on a fixed timer.
- In src/main.rs, find the imports and constants at the top of the file. Replace everything from the first use line down to the BOARD_HEIGHT constant with this updated version:
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::DefaultTerminal;
const BOARD_WIDTH: u16 = 40;
const BOARD_HEIGHT: u16 = 20;
const TICK_MS: u64 = 200;
What changed here?
- Duration and Instant from the standard library let you measure elapsed time and define tick intervals.
- KeyCode is added to the crossterm import so you can match on specific keys like arrow keys and 'q'.
- TICK_MS sets the game speed to 200 milliseconds per frame, giving you 5 updates per second.
The snake also needs to know whether the game has ended. Right now the Game struct has no way to track that.
- Find the Game struct definition. Locate this line:
score: u32,
- Add a game_over field directly below it, so the struct now looks like this:
score: u32,
game_over: bool,
- Inside Game::new(), find the line that says Game { snake, direction: Dir::Right, score: 0, and add game_over: false, so the constructor looks like this:
Game { snake, direction: Dir::Right, score: 0, game_over: false, board_width: bw, board_height: bh }
Now you can build the method that actually moves the snake forward on each tick.
- Inside the impl Game block, add this tick() method below the new() function:
fn tick(&mut self) {
if self.game_over { return; }
// Calculate the new head position based on direction
let (hx, hy) = self.snake[0];
let new_head = match self.direction {
Dir::Up => (hx, hy - 1),
Dir::Down => (hx, hy + 1),
Dir::Left => (hx - 1, hy),
Dir::Right => (hx + 1, hy),
};
// Move: add new head, remove tail
self.snake.push_front(new_head);
self.snake.pop_back();
}
What does this code do?
- The match on self.direction calculates where the head should be next. Moving up means subtracting 1 from y, moving right means adding 1 to x, and so on.
- push_front(new_head) adds the new head to the front of the VecDeque, and pop_back() removes the tail. This creates the illusion of movement without rebuilding the whole snake.
- Save src/main.rs and confirm the code compiles by running:
cargo check
You should see a warning about unused imports and variables, but no errors. The code compiles.
Seeing a compile error?
- Check that game_over: bool appears in the Game struct and game_over: false appears in Game::new(). Missing either one causes a struct-field mismatch.
- Make sure tick() is inside the impl Game { ... } block, not outside it.
- help me fix a compile error in my snake game tick method
Now you need to wire tick() into a real game loop that calls it on a timer instead of blocking on input.
- Find the existing run_game() function and replace the entire function with this new version:
fn run_game(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
let mut game = Game::new();
let mut last_tick = Instant::now();
let tick_duration = Duration::from_millis(TICK_MS);
loop {
// Redraw the screen every iteration
terminal.draw(|frame| {
let outer = Layout::horizontal([
Constraint::Length(BOARD_WIDTH),
Constraint::Min(20),
]).split(frame.area());
render_board(frame, &game, outer[0]);
render_sidebar(frame, &game, outer[1]);
})?;
// Exit if the game is over
if game.game_over { return Ok(()); }
// Poll for input without blocking
let timeout = tick_duration.saturating_sub(last_tick.elapsed());
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if !key.is_press() { continue; }
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
}
}
// Advance game state on each tick
if last_tick.elapsed() >= tick_duration {
game.tick();
last_tick = Instant::now();
}
}
}
What does this code do?
This is the game loop pattern. Instead of blocking forever on event::read(), the loop uses event::poll(timeout) to check for input for a limited time, then moves on.
- saturating_sub calculates how much time is left until the next tick. If the tick is overdue, the timeout is zero and the loop processes input instantly.
- The if last_tick.elapsed() >= tick_duration block fires every 200ms. It calls tick() to advance the snake and resets the timer.
- is_press() filters out key-release and key-repeat events, which some terminals (especially on Windows) send alongside the press.
- Save src/main.rs and run the game:
cargo run
You should see the snake moving to the right on its own. After a few seconds it reaches the edge and vanishes off the board. Press 'q' to exit.
Snake not moving?
- Make sure you replaced the entire old run_game() function, including the old event::read() call. The old blocking read prevents the game loop from ticking.
- Check that game is declared as let mut game. Without mut, the compiler will reject the game.tick() call.
- help me debug why my snake game loop isn't working
Handle keyboard input
The snake moves, but you cannot steer it yet. You need to map arrow keys to direction changes. You also need to prevent the snake from reversing directly into itself, because a snake moving right that suddenly moves left would eat its own neck.
- Below the enum Dir { Up, Down, Left, Right } definition, add this impl Dir block:
impl Dir {
fn opposite(self) -> Self {
match self {
Dir::Up => Dir::Down,
Dir::Down => Dir::Up,
Dir::Left => Dir::Right,
Dir::Right => Dir::Left,
}
}
}
What does this code do?
opposite() returns the direction that faces the other way. The game will use this to reject any direction change that would send the snake straight back into its own body.
- Inside the impl Game block, add this change_direction() method below tick():
fn change_direction(&mut self, new_dir: Dir) {
if new_dir != self.direction.opposite() {
self.direction = new_dir;
}
}
Now wire these methods into the game loop so arrow keys actually do something.
- In run_game(), find the match key.code block. It currently looks like this:
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
- Add four arrow key arms above the KeyCode::Char('q') line, so the full match block looks like this:
match key.code {
KeyCode::Up => game.change_direction(Dir::Up),
KeyCode::Down => game.change_direction(Dir::Down),
KeyCode::Left => game.change_direction(Dir::Left),
KeyCode::Right => game.change_direction(Dir::Right),
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
- Save src/main.rs and run the game:
cargo run
The snake moves to the right. Press the arrow keys to steer it. Try pressing the opposite direction (left while moving right). The snake ignores the reversal.
Arrow keys not working?
- Make sure the impl Dir block is outside impl Game. They are two separate impl blocks.
- Check that change_direction() is inside impl Game, not inside impl Dir.
- help me debug arrow key input in my Ratatui snake game
Add boundary detection
Right now the snake silently vanishes when it leaves the board. A real game should detect when the snake hits a wall and end the round.
- In the tick() method, find the closing brace of the match self.direction block. It currently looks like this:
};
// Move: add new head, remove tail
self.snake.push_front(new_head);
- Add a boundary check between the match closing brace and push_front. Replace those three lines so they look like this:
};
// Check if the snake hit a wall
if new_head.0 < 0 || new_head.0 >= self.board_width || new_head.1 < 0 || new_head.1 >= self.board_height {
self.game_over = true;
return;
}
// Move: add new head, remove tail
self.snake.push_front(new_head);
What does this code do?
Before moving the snake, the code checks whether the new head position is outside the board. If any coordinate is negative or beyond the board dimensions, game_over is set to true and the method returns early, skipping the move.
Back in run_game(), the line if game.game_over { return Ok(()); } catches this flag on the next loop iteration and exits the program.
- Save src/main.rs.
Before you run this, think about what will happen when the snake reaches the edge of the board. Will it wrap around? Show a message? Something else?
- Run the game and steer the snake into a wall:
cargo run
The snake hits the wall and the program just exits. You are back at your terminal prompt with no message, no score, nothing. That abrupt exit is expected for now.
Why does it just exit?
When game_over becomes true, the game loop returns Ok(()) immediately. There is no game-over screen yet. Step 5 will add a proper overlay with your final score and a restart option.
🙋♀️ Program panics instead of exiting cleanly?
- Make sure the boundary check comes before self.snake.push_front(new_head). If you placed it after, the snake moves out of bounds before the check runs.
- Verify the check uses self.board_width and self.board_height, not BOARD_WIDTH and BOARD_HEIGHT. The struct fields already subtract the border padding.
- help me fix a panic in my Rust snake game boundary detection
✔️ Awesome, I've got everything!
Great. Double check you have saved src/main.rs.
ⓧ I'd like to double check the full code
Your src/main.rs is now too long to show in a single block. Scroll up and confirm you have each of these pieces, in order:
- Imports including std::time::{Duration, Instant} and KeyCode.
- Three constants: BOARD_WIDTH, BOARD_HEIGHT, and TICK_MS.
- The Dir enum with an impl Dir block containing opposite().
- The Game struct with the game_over: bool field.
- impl Game with new(), tick() (including the boundary check), and change_direction().
- The run_game() function with the poll-based loop, arrow key matching, and tick timing.
- render_board() and render_sidebar() unchanged from the previous step.
Your snake is alive. It moves, it steers, and it crashes into walls. But there is no reason to keep moving. Next up, you will add food that spawns on the board and makes the snake grow when eaten.
Add Food and Growth
Your snake moves around the board and responds to arrow keys. Right now, though, there is nothing to chase. The snake just wanders until it crashes into a wall.
Food is what makes Snake fun. In this step, you will add a food item that spawns at random positions, grows the snake when eaten, and tracks your score.
In this step, get ready to:
- Add a food position to the game state and spawn it randomly.
- Render food as a red block character on the board.
- Make the snake grow and update the score when it eats food.
Add food to the game state
The food needs three things: a position on the board, a way to spawn at a random location, and logic to avoid landing on top of the snake.
- In src/main.rs, find the imports at the top of the file. Add this line after the existing crossterm import:
use rand::RngExt;
- Next, find the Game struct. Add a food field between direction and score, so the struct looks like this:
struct Game {
snake: VecDeque<(i16, i16)>,
direction: Dir,
food: (i16, i16),
score: u32,
game_over: bool,
board_width: i16,
board_height: i16,
}
- Now replace the existing Game::new() method and add a new spawn_food() method right below it by copying the following code:
fn new() -> Self {
let bw = (BOARD_WIDTH - 2) as i16;
let bh = (BOARD_HEIGHT - 2) as i16;
let mut snake = VecDeque::new();
snake.push_back((bw / 2, bh / 2));
snake.push_back((bw / 2 - 1, bh / 2));
snake.push_back((bw / 2 - 2, bh / 2));
// Start with a placeholder food position, then spawn real food
let mut game = Game { snake, direction: Dir::Right, food: (0, 0), score: 0, game_over: false, board_width: bw, board_height: bh };
game.spawn_food();
game
}
fn spawn_food(&mut self) {
let mut rng = rand::rng();
loop {
// Pick a random cell inside the board
let x = rng.random_range(0..self.board_width);
let y = rng.random_range(0..self.board_height);
// Only place food where the snake isn't
if !self.snake.contains(&(x, y)) {
self.food = (x, y);
break;
}
}
}
What does this code do?
The updated new() constructor creates the Game with a placeholder food: (0, 0) position, then immediately calls spawn_food() to pick a real random location.
The spawn_food() method loops until it finds a random cell that does not overlap any part of the snake. This prevents food from spawning inside the snake's body.
- Save src/main.rs.
- Verify the code compiles by running:
cargo run
The game should compile and run. The snake still moves as before. You will not see the food yet because the rendering code does not know about it.
Seeing a compilation error?
- Check that the use rand::RngExt; import is on its own line after the crossterm import.
- Make sure the Game struct now includes food: (i16, i16) and the constructor initializes it with food: (0, 0).
- Confirm that spawn_food() is inside the impl Game block, not outside it.
Help me debug a Rust compilation error with the spawn_food method in my Snake game
Render the food on the board
The food exists in the game state, but the render_board function does not draw it yet. You need to add a check for the food position when building each cell.
- In the render_board function, find these lines inside the .map(|x| closure:
} else if game.snake.contains(&pos) {
Span::styled("\u{2588}", Style::default().fg(Color::Green))
} else {
Span::raw(" ")
}
- Add a new else if branch for the food between the snake body check and the empty space fallback, so the block looks like this:
} else if game.snake.contains(&pos) {
Span::styled("\u{2588}", Style::default().fg(Color::Green))
} else if game.food == pos {
// Render food as a red block character
Span::styled("\u{2588}", Style::default().fg(Color::Red))
} else {
Span::raw(" ")
}
What's happening here?
The rendering loop checks each cell position in order: is it the snake's head, is it a body segment, is it food, or is it empty space. The food uses Color::Red so it stands out against the green snake.
- Save src/main.rs.
Before you run this, what do you expect to see on the board now?
- Run the game to see your food by running:
cargo run
You should see a red block character somewhere on the board alongside the green snake. The food spawns at a random position each time you start the game.
Don't see the red food block?
- Make sure the else if game.food == pos branch comes before the final else { Span::raw(" ") } fallback. If it comes after, the empty space catches everything first.
- Check that you are comparing game.food (not self.food) since render_board is a standalone function, not a method on Game.
Help me figure out why my food isn't rendering in my Ratatui Snake game
Handle eating and growth
The food is visible, but nothing happens when the snake runs over it. The tick() method always pops the tail after each move. To make the snake grow, you skip that pop when the head lands on food.
- In the tick() method, find these two lines near the bottom of the function:
self.snake.push_front(new_head);
self.snake.pop_back();
- Replace those two lines with the following eating logic:
self.snake.push_front(new_head);
// Check if the snake ate food
if new_head == self.food {
self.score += 1;
self.spawn_food();
} else {
// No food eaten, remove the tail to maintain length
self.snake.pop_back();
}
What does this code do?
- The snake's head is always pushed to the front of the VecDeque regardless of whether it ate food.
- If the head lands on the food, the score goes up and new food spawns. The tail is NOT popped, so the snake is one segment longer.
- If the head did not land on food, the tail is popped as usual, keeping the snake the same length.
- Save src/main.rs.
Before you run this, what do you think will happen when the snake reaches the food?
- Run the game by running:
cargo run
Steer the snake into the red food block. The food disappears, a new one spawns somewhere else, the snake grows by one segment, and the score in the sidebar increases.
Score not updating or snake not growing?
- Make sure you replaced both the push_front and pop_back lines with the new code. If you left the old pop_back() in place outside the if/else, the tail will always be removed and the snake will never grow.
- Verify that new_head == self.food uses == (two equals signs), not = (assignment).
Help me debug why eating food isn't working in my Snake game
✔️ Awesome, I've got everything!
Great. Double check that you have saved src/main.rs.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm each section is present in src/main.rs, in this order:
- The use rand::RngExt; import after the crossterm import.
- The Game struct with the food: (i16, i16) field between direction and score.
- The updated Game::new() constructor that initializes food: (0, 0) and calls game.spawn_food().
- The spawn_food() method using rand::rng().random_range() in a loop.
- The tick() method with the if new_head == self.food eating logic replacing the old pop_back().
- The render_board function with the else if game.food == pos branch rendering red.
Your snake now eats, grows, and scores. The core gameplay loop is in place. Next up, you will add a proper game over screen so the player gets feedback when they crash, plus a way to restart without relaunching the program.
Collision and Game Over
Your snake can eat, grow, and rack up a score. But when it hits a wall, the program silently exits with zero feedback.
A real game needs a proper ending. In this step, you will add self-collision detection, build a game over popup with your final score, and wire up a restart key so the player can jump right back in.
In this step, get ready to:
- Detect when the snake collides with its own body.
- Render a centered game over overlay with the final score.
- Handle restart and quit inputs from the game over screen.
Add self-collision detection
The snake currently crashes through its own body as if it is not there. A real Snake game ends when the head runs into any part of the tail.
- In src/main.rs, scroll to the tick() method inside impl Game.
- Find the wall collision check. It looks like this:
if new_head.0 < 0 || new_head.0 >= self.board_width || new_head.1 < 0 || new_head.1 >= self.board_height {
self.game_over = true;
return;
}
self.snake.push_front(new_head);
- Add a self-collision check between the wall check and the push_front line, so the section now looks like this:
if new_head.0 < 0
|| new_head.0 >= self.board_width
|| new_head.1 < 0
|| new_head.1 >= self.board_height
{
self.game_over = true;
return;
}
// End the game if the snake runs into itself
if self.snake.contains(&new_head) {
self.game_over = true;
return;
}
self.snake.push_front(new_head);
What does this check do?
self.snake.contains(&new_head) scans the entire VecDeque of body segments. If the new head position matches any existing segment, the snake has bitten itself and the game ends.
- Save src/main.rs.
- Run the game by running:
cargo run
- Steer the snake into a U-turn so it runs back into its own body. The program exits immediately, just like a wall collision.
Snake doesn't exit when hitting itself?
- Make sure the new if self.snake.contains(&new_head) block is placed after the wall check and before self.snake.push_front(new_head). If it runs after the push, the head is already in the deque and it always triggers.
- Check that both lines inside the block say self.game_over = true; and return;.
- help me debug why self-collision is not working in my Snake game
Build the game over popup
Right now the game just vanishes on collision. You need a visible game over screen that shows the player's score and tells them what to do next.
In Ratatui, a popup is an overlay rendered on top of existing content. You compute a centered Rect using nested Layout splits, then render your popup widget into that area.
- First, update your imports at the top of src/main.rs. Find this line:
use ratatui::layout::{Constraint, Layout, Rect};
- Add Direction to that import so it looks like this:
use ratatui::layout::{Constraint, Direction, Layout, Rect};
- Then find this line:
use ratatui::style::{Color, Style};
- Add Stylize so it looks like this:
use ratatui::style::{Color, Style, Stylize};
- Now add the centered_rect helper function at the very bottom of src/main.rs, after the render_sidebar function, by copying this code:
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
// Cut the area into three vertical slices: margin, popup, margin
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
// Cut the middle slice into three horizontal pieces and return the center
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
How does centered_rect work?
This is a common Ratatui pattern for creating popup overlays. It splits the parent area twice.
First, it cuts the area vertically into three rows using Constraint::Percentage. The top and bottom rows are equal-sized margins, and the middle row is your popup height. Then it cuts that middle row horizontally the same way, producing left margin, popup width, and right margin. The center cell is your popup.
- Now add the render_game_over function between render_sidebar and centered_rect by copying this code:
fn render_game_over(frame: &mut ratatui::Frame, game: &Game, area: Rect) {
let popup = centered_rect(60, 40, area);
let block = Block::bordered()
.title("Game Over")
.style(Style::default().fg(Color::Red));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let text = vec![
Line::raw(""),
Line::from("Game Over!".red().bold()),
Line::raw(""),
Line::from(vec![
Span::raw("Final Score: "),
Span::styled(game.score.to_string(), Style::default().fg(Color::Yellow)),
]),
Line::raw(""),
Line::raw("'r' to restart"),
Line::raw("'q' to quit"),
];
frame.render_widget(Paragraph::new(text).centered(), inner);
}
What does this function do?
- centered_rect(60, 40, area) computes a popup that takes up 60% of the width and 40% of the height of the game area, centered in the middle.
- The Stylize trait enables the "Game Over!".red().bold() chaining syntax, which is a convenient shorthand for building styled text.
- .centered() on the Paragraph aligns the text to the center of the popup area.
The two new functions exist but nothing calls them yet. You need to wire them into the game loop.
- In the run_game function, find the draw closure and the lines right after it. It currently looks like this:
loop {
terminal.draw(|frame| {
let outer = Layout::horizontal([
Constraint::Length(BOARD_WIDTH),
Constraint::Min(20),
]).split(frame.area());
render_board(frame, &game, outer[0]);
render_sidebar(frame, &game, outer[1]);
})?;
if game.game_over { return Ok(()); }
let timeout = tick_duration.saturating_sub(last_tick.elapsed());
- Replace that entire section (from loop { down to and including the let timeout line) with this updated version:
loop {
terminal.draw(|frame| {
let outer = Layout::horizontal([
Constraint::Length(BOARD_WIDTH),
Constraint::Min(20),
])
.split(frame.area());
let game_area = outer[0];
let sidebar_area = outer[1];
render_board(frame, &game, game_area);
render_sidebar(frame, &game, sidebar_area);
// Show the game over popup on top of the board
if game.game_over {
render_game_over(frame, &game, game_area);
}
})?;
let tick_duration = Duration::from_millis(TICK_MS);
let timeout = tick_duration.saturating_sub(last_tick.elapsed());
What changed here?
- The layout results are now named game_area and sidebar_area for clarity.
- When game_over is true, render_game_over draws the popup on top of the board. Ratatui renders in order, so widgets drawn later overwrite earlier ones.
- The if game.game_over { return Ok(()); } early exit is gone. The loop keeps running so the popup stays visible.
- tick_duration moved inside the loop. This prepares the structure for dynamic speed later.
- Also find and delete the old let tick_duration = Duration::from_millis(TICK_MS); line that sits before the loop {. It now lives inside the loop instead.
- Save src/main.rs and run the game:
cargo run
- Steer the snake into a wall. You should see a red-bordered "Game Over" popup appear in the center of the board, showing your final score.
- Press q to exit. The restart key does not work yet.
Don't see the popup?
- Make sure you removed the if game.game_over { return Ok(()); } line. If it is still there, the game exits before it can draw the popup.
- Check that render_game_over is called inside the draw closure, after render_board and render_sidebar. Rendering order matters in Ratatui.
- Confirm you deleted the old let tick_duration line from before the loop. Having it declared twice causes a compile error.
- help me fix my game over popup in Ratatui
Handle restart and quit
The popup shows, but pressing any key still tries to move the snake. You need the game to listen for different keys depending on whether the game is over or still playing.
- First, add a reset method to Game. Inside impl Game, add this method after change_direction:
fn reset(&mut self) {
*self = Game::new();
}
- Now update the key handling in run_game. Find the current key event block. It looks like this:
if let Event::Key(key) = event::read()? {
if !key.is_press() { continue; }
match key.code {
KeyCode::Up => game.change_direction(Dir::Up),
KeyCode::Down => game.change_direction(Dir::Down),
KeyCode::Left => game.change_direction(Dir::Left),
KeyCode::Right => game.change_direction(Dir::Right),
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
}
- Replace that entire block with this updated version that branches on the game state:
if let Event::Key(key) = event::read()? {
if !key.is_press() {
continue;
}
if game.game_over {
// Game over: only accept restart or quit
match key.code {
KeyCode::Char('r') => game.reset(),
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
} else {
// Playing: accept movement and quit
match key.code {
KeyCode::Up => game.change_direction(Dir::Up),
KeyCode::Down => game.change_direction(Dir::Down),
KeyCode::Left => game.change_direction(Dir::Left),
KeyCode::Right => game.change_direction(Dir::Right),
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
_ => {}
}
}
}
What does this change?
- When game_over is true, the only accepted keys are r (restart) and q/Esc (quit). Arrow keys are ignored.
- game.reset() replaces the entire Game struct with a fresh one using *self = Game::new(). This resets the snake position, direction, score, food, and game_over flag all at once.
- Save src/main.rs.
Before you run this, what do you expect to happen when you press r on the game over screen?
- Run the game by running:
cargo run
- Play until you hit a wall or your own tail. You should see the game over popup with your score.
- Press r. The game restarts from scratch with a fresh snake, score back to 0, and new food.
- Press q to exit cleanly when you are done.
Pressing 'r' does nothing?
- Make sure the reset method is inside impl Game, not outside it. The method needs to be on the Game struct.
- Check that the if game.game_over branch is the first check inside the key event handler. If the playing branch runs first, the r key falls into the _ => {} catch-all and does nothing.
- help me debug why restart is not working in my Snake game
✔️ Awesome, I've got everything!
Great. Double check that you have saved src/main.rs.
ⓧ I'd like to double check the full code
The full src/main.rs file is longer than 30 lines, so scroll through your file and confirm each section is present in this order:
- Imports including Direction and Stylize.
- Constants: BOARD_WIDTH, BOARD_HEIGHT, TICK_MS.
- Dir enum with opposite() method.
- Game struct and impl Game with new(), spawn_food(), tick() (with both wall and self-collision), change_direction(), and reset().
- main() calling ratatui::run().
- run_game() with the updated draw closure (including render_game_over call), branched key handling, and tick_duration inside the loop.
- render_board(), render_sidebar(), render_game_over(), and centered_rect() at the bottom.
You have a complete, playable Snake game with self-collision, wall collision, a game over screen, and a restart loop. Every core game mechanic is in place.
Secret mission
Add Speed Scaling
Your Snake game plays at the same constant speed no matter how high your score gets. Real arcade games ramp up the difficulty the longer you survive. Can you make the snake move faster as your score climbs, turning a casual game into a sweaty challenge?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with free, open-source tools. There are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files or delete them entirely.
Resources you used:
- The snake-game project folder on your Desktop (contains Cargo.toml, src/main.rs, and compiled artifacts in target/).
Keep everything running
No action needed. Choose this if you want to keep playing or keep tweaking your Snake game.
- Your snake-game folder stays on your Desktop.
- Run cargo run from inside the folder any time to play.
- Rust, Cargo, and your code editor remain installed on your machine.
Pause - I'll come back to this later
There are no running processes or services to pause. Your project is just files on disk, so there is nothing extra to shut down.
- You can free up disk space from compiled artifacts by running this command inside your snake-game folder:
cargo clean
- This removes the target/ directory (compiled output) while keeping your source code intact.
- When you come back, run cargo run to recompile and play again.
Delete - I don't want to use this again
Remove the entire project folder to start fresh.
- Delete the snake-game folder by running this command on macOS or Linux:
rm -rf ~/Desktop/snake-game
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\snake-game
- This removes all source code, the Cargo.toml, and compiled artifacts in one step.
- Confirm the folder is gone by running:
ls ~/Desktop/snake-game
- You should see a "No such file or directory" message.
That's a Wrap!
That's a Wrap!
Nice work. You just built a fully playable Snake game that runs entirely in your terminal, powered by Rust and Ratatui.
You've learned how to:
- Build a terminal UI using Ratatui's core APIs: Layout for splitting the screen, Block for borders, Paragraph/Line/Span for styled text, and Style/Color for visual styling.
- Implement a real-time game loop with Crossterm's poll/read event system, keyboard input handling, collision detection, and game state management in Rust.
- Render game graphics using Unicode block characters and build a complete game experience with food spawning, score tracking, and a game over screen with restart.
- Secret Mission: made the game progressively harder with dynamic speed scaling based on the player's score.
Ready to quiz yourself?