Neste projeto vamos construir o clássico jogo Snake (cobrinha) diretamente no terminal usando Rust e a crate crossterm para manipulação de terminal. A cobra se move continuamente pela tela, o jogador controla a direção com as setas do teclado, e o objetivo é comer as frutas que aparecem aleatoriamente sem colidir consigo mesma ou com as paredes. A cada fruta consumida, a cobra cresce e a velocidade aumenta.
Este é um projeto excelente para aprender sobre game loops, manipulação de terminal em modo raw, entrada de teclado não-bloqueante e estruturas de dados como VecDeque, que é perfeita para representar o corpo da cobra.
O Que Vamos Construir
Um jogo Snake completo com as seguintes funcionalidades:
- Cobra que se move continuamente em uma direção
- Controle por setas do teclado (cima, baixo, esquerda, direita)
- Frutas que aparecem em posições aleatórias
- Crescimento da cobra ao comer frutas
- Detecção de colisão com paredes e com o próprio corpo
- Pontuação exibida em tempo real
- Velocidade crescente conforme a pontuação aumenta
- Tela de game over com opção de reiniciar
Estrutura do Projeto
snake-game/
├── Cargo.toml
└── src/
└── main.rs
Configurando o Projeto
Crie o projeto com o Cargo:
cargo new snake-game
cd snake-game
Edite o Cargo.toml com as dependências necessárias:
[package]
name = "snake-game"
version = "0.1.0"
edition = "2021"
[dependencies]
crossterm = "0.28"
rand = "0.8"
Usamos crossterm para controle do terminal (modo raw, posicionamento do cursor, leitura de teclas) e rand para gerar posições aleatórias das frutas.
Passo 1: Definindo os Tipos e Estruturas
Vamos começar definindo as estruturas fundamentais do jogo: posição, direção, cobra e estado do jogo.
use crossterm::{
cursor, event::{self, Event, KeyCode, KeyEvent},
execute, queue,
style::{self, Stylize},
terminal::{self, ClearType},
};
use rand::Rng;
use std::collections::VecDeque;
use std::io::{self, Write};
use std::time::{Duration, Instant};
/// Posição (x, y) no tabuleiro
#[derive(Clone, Copy, PartialEq)]
struct Posicao {
x: u16,
y: u16,
}
/// Direção de movimento da cobra
#[derive(Clone, Copy, PartialEq)]
enum Direcao {
Cima,
Baixo,
Esquerda,
Direita,
}
impl Direcao {
/// Verifica se duas direções são opostas (para impedir inversão)
fn eh_oposta(&self, outra: &Direcao) -> bool {
matches!(
(self, outra),
(Direcao::Cima, Direcao::Baixo)
| (Direcao::Baixo, Direcao::Cima)
| (Direcao::Esquerda, Direcao::Direita)
| (Direcao::Direita, Direcao::Esquerda)
)
}
}
/// Estado completo do jogo
struct Jogo {
cobra: VecDeque<Posicao>,
direcao: Direcao,
fruta: Posicao,
pontuacao: u32,
largura: u16,
altura: u16,
game_over: bool,
}
Usamos VecDeque para o corpo da cobra porque precisamos adicionar elementos na frente (cabeça) e remover do final (cauda) de forma eficiente – ambas operações O(1). A struct Jogo mantém todo o estado necessário em um único lugar.
Passo 2: Inicialização e Geração de Frutas
Agora implementamos a criação do jogo e a lógica para posicionar frutas em locais válidos.
impl Jogo {
/// Cria um novo jogo com as dimensões do terminal
fn novo(largura: u16, altura: u16) -> Self {
let centro_x = largura / 2;
let centro_y = altura / 2;
// Cobra começa com 3 segmentos no centro
let mut cobra = VecDeque::new();
cobra.push_back(Posicao { x: centro_x, y: centro_y });
cobra.push_back(Posicao { x: centro_x - 1, y: centro_y });
cobra.push_back(Posicao { x: centro_x - 2, y: centro_y });
let mut jogo = Jogo {
cobra,
direcao: Direcao::Direita,
fruta: Posicao { x: 0, y: 0 },
pontuacao: 0,
largura,
altura,
game_over: false,
};
jogo.gerar_fruta();
jogo
}
/// Gera uma fruta em posição aleatória que não sobreponha a cobra
fn gerar_fruta(&mut self) {
let mut rng = rand::thread_rng();
loop {
let pos = Posicao {
x: rng.gen_range(1..self.largura - 1),
y: rng.gen_range(1..self.altura - 1),
};
// Garante que a fruta não aparece sobre a cobra
if !self.cobra.contains(&pos) {
self.fruta = pos;
break;
}
}
}
/// Calcula o intervalo entre frames baseado na pontuação
fn velocidade(&self) -> Duration {
let milissegundos = 150u64.saturating_sub(self.pontuacao as u64 * 5);
Duration::from_millis(milissegundos.max(50))
}
}
A cobra começa com 3 segmentos no centro da tela, movendo-se para a direita. A geração de frutas usa um loop que garante que a nova posição não coincida com nenhum segmento da cobra. A velocidade diminui o intervalo entre frames conforme a pontuação sobe, até um mínimo de 50ms.
Passo 3: Lógica de Atualização e Renderização
Aqui implementamos o coração do jogo: mover a cobra, detectar colisões e desenhar tudo no terminal.
impl Jogo {
/// Atualiza o estado do jogo: move a cobra, verifica colisões
fn atualizar(&mut self) {
// Calcula a nova posição da cabeça
let cabeca = self.cobra.front().unwrap();
let nova_cabeca = match self.direcao {
Direcao::Cima => Posicao { x: cabeca.x, y: cabeca.y.wrapping_sub(1) },
Direcao::Baixo => Posicao { x: cabeca.x, y: cabeca.y + 1 },
Direcao::Esquerda => Posicao { x: cabeca.x.wrapping_sub(1), y: cabeca.y },
Direcao::Direita => Posicao { x: cabeca.x + 1, y: cabeca.y },
};
// Verifica colisão com paredes
if nova_cabeca.x == 0
|| nova_cabeca.x >= self.largura - 1
|| nova_cabeca.y == 0
|| nova_cabeca.y >= self.altura - 1
{
self.game_over = true;
return;
}
// Verifica colisão com o próprio corpo
if self.cobra.contains(&nova_cabeca) {
self.game_over = true;
return;
}
// Move a cabeça para a nova posição
self.cobra.push_front(nova_cabeca);
// Verifica se comeu a fruta
if nova_cabeca == self.fruta {
self.pontuacao += 1;
self.gerar_fruta();
// Não remove a cauda (cobra cresce)
} else {
// Remove a cauda (cobra mantém o tamanho)
self.cobra.pop_back();
}
}
/// Desenha as bordas do campo de jogo
fn desenhar_bordas(&self, stdout: &mut io::Stdout) {
// Borda superior
for x in 0..self.largura {
queue!(stdout, cursor::MoveTo(x, 0), style::PrintStyledContent("█".dark_grey())).unwrap();
}
// Borda inferior
for x in 0..self.largura {
queue!(stdout, cursor::MoveTo(x, self.altura - 1), style::PrintStyledContent("█".dark_grey())).unwrap();
}
// Bordas laterais
for y in 0..self.altura {
queue!(stdout, cursor::MoveTo(0, y), style::PrintStyledContent("█".dark_grey())).unwrap();
queue!(stdout, cursor::MoveTo(self.largura - 1, y), style::PrintStyledContent("█".dark_grey())).unwrap();
}
}
/// Renderiza o estado atual do jogo no terminal
fn renderizar(&self, stdout: &mut io::Stdout) {
queue!(stdout, terminal::Clear(ClearType::All)).unwrap();
// Desenha as bordas
self.desenhar_bordas(stdout);
// Desenha a fruta
queue!(
stdout,
cursor::MoveTo(self.fruta.x, self.fruta.y),
style::PrintStyledContent("●".red())
).unwrap();
// Desenha a cobra
for (i, segmento) in self.cobra.iter().enumerate() {
let caractere = if i == 0 { "◆" } else { "■" };
let estilo = if i == 0 {
caractere.green().bold()
} else {
caractere.green()
};
queue!(
stdout,
cursor::MoveTo(segmento.x, segmento.y),
style::PrintStyledContent(estilo)
).unwrap();
}
// Desenha a pontuação no topo
let info = format!(" Pontuação: {} | Tamanho: {} ", self.pontuacao, self.cobra.len());
queue!(
stdout,
cursor::MoveTo(2, 0),
style::PrintStyledContent(info.yellow().bold())
).unwrap();
stdout.flush().unwrap();
}
}
A função atualizar implementa a mecânica central: calcula a nova posição da cabeça, verifica colisões, e decide se a cauda deve ser removida (movimento normal) ou mantida (cobra comeu fruta e cresce). A renderização usa queue! do crossterm para acumular comandos e fazer flush uma única vez, evitando flickering.
Passo 4: Game Loop e main.rs Completo
Agora juntamos tudo no loop principal com leitura de teclado não-bloqueante.
/// Exibe a tela de game over e retorna se o jogador quer reiniciar
fn tela_game_over(stdout: &mut io::Stdout, pontuacao: u32) -> bool {
let msg_titulo = " GAME OVER ";
let msg_pontuacao = format!(" Pontuação final: {} ", pontuacao);
let msg_reiniciar = " Pressione R para reiniciar ou Q para sair ";
execute!(
stdout,
cursor::MoveTo(10, 5),
style::PrintStyledContent(msg_titulo.red().bold()),
cursor::MoveTo(10, 7),
style::PrintStyledContent(msg_pontuacao.white()),
cursor::MoveTo(10, 9),
style::PrintStyledContent(msg_reiniciar.dark_grey()),
).unwrap();
loop {
if event::poll(Duration::from_millis(100)).unwrap() {
if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
match code {
KeyCode::Char('r') | KeyCode::Char('R') => return true,
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => return false,
_ => {}
}
}
}
}
}
fn main() {
// Configura o terminal em modo raw
terminal::enable_raw_mode().unwrap();
let mut stdout = io::stdout();
execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide).unwrap();
// Obtém as dimensões do terminal
let (largura, altura) = terminal::size().unwrap();
let largura_jogo = largura.min(60);
let altura_jogo = altura.min(25);
'jogo_principal: loop {
let mut jogo = Jogo::novo(largura_jogo, altura_jogo);
let mut ultimo_tick = Instant::now();
// Game loop
loop {
// Processa entrada de teclado (não-bloqueante)
if event::poll(Duration::from_millis(10)).unwrap() {
if let Event::Key(KeyEvent { code, .. }) = event::read().unwrap() {
let nova_direcao = match code {
KeyCode::Up => Some(Direcao::Cima),
KeyCode::Down => Some(Direcao::Baixo),
KeyCode::Left => Some(Direcao::Esquerda),
KeyCode::Right => Some(Direcao::Direita),
KeyCode::Char('q') | KeyCode::Esc => break 'jogo_principal,
_ => None,
};
// Impede que a cobra inverta a direção sobre si mesma
if let Some(dir) = nova_direcao {
if !dir.eh_oposta(&jogo.direcao) {
jogo.direcao = dir;
}
}
}
}
// Atualiza o jogo no ritmo definido pela velocidade
if ultimo_tick.elapsed() >= jogo.velocidade() {
jogo.atualizar();
ultimo_tick = Instant::now();
if jogo.game_over {
jogo.renderizar(&mut stdout);
if tela_game_over(&mut stdout, jogo.pontuacao) {
break; // Reinicia o jogo
} else {
break 'jogo_principal; // Sai do programa
}
}
jogo.renderizar(&mut stdout);
}
}
}
// Restaura o terminal ao estado original
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen).unwrap();
terminal::disable_raw_mode().unwrap();
println!("Obrigado por jogar Snake!");
}
O game loop usa event::poll com timeout curto para leitura não-bloqueante do teclado, permitindo que o jogo continue atualizando mesmo sem input. O Instant controla o ritmo de atualização conforme a velocidade atual. Usamos terminal::EnterAlternateScreen para preservar o conteúdo original do terminal e restaurá-lo ao sair.
Como Executar
Compile e execute o projeto:
cargo run
Controles:
- Setas do teclado: Mudar direção da cobra
- Q ou Esc: Sair do jogo
- R: Reiniciar após game over
Para a melhor experiência, use um terminal com pelo menos 60 colunas e 25 linhas. Para compilar em modo otimizado:
cargo build --release
./target/release/snake-game
Desafios para Expandir
Modos de tabuleiro – Adicione um modo “sem paredes” onde a cobra atravessa de um lado ao outro da tela (wrapping), e um modo com obstáculos fixos no campo.
Sistema de recordes – Salve os 10 melhores scores em um arquivo local com nome do jogador e data, exibindo uma tabela de classificação na tela inicial.
Frutas especiais – Implemente diferentes tipos de frutas com cores distintas: frutas douradas que valem pontos extras, frutas que reduzem a velocidade temporariamente, e frutas venenosas que reduzem o tamanho.
Efeitos sonoros – Integre a crate
rodiopara tocar sons curtos ao comer frutas, colidir e ao fundo um loop de música 8-bit.Multiplayer local – Adicione uma segunda cobra controlada por WASD, permitindo dois jogadores competirem no mesmo terminal com detecção de colisão entre as cobras.
Veja Também
- Vec e Coleções Dinâmicas – Como funciona o Vec usado em diversas partes do jogo
- VecDeque para Filas Duplas – A estrutura ideal para representar a cobra
- Medição de Tempo – Controle de tempo para o game loop
- Lendo Input do Usuário – Padrões de leitura de entrada no terminal
- Rust para Jogos – Panorama do ecossistema de jogos em Rust