Jogo Snake no Terminal em Rust

Construa o clássico jogo Snake no terminal com Rust usando crossterm: game loop, controle por teclado, colisão, pontuação e velocidade.

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

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

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

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

  4. Efeitos sonoros – Integre a crate rodio para tocar sons curtos ao comer frutas, colidir e ao fundo um loop de música 8-bit.

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