Jogo da Velha no Terminal em Rust

Construa um jogo da velha completo no terminal com Rust: modo dois jogadores, IA com minimax, tabuleiro colorido e detecção de vitória.

Neste projeto vamos construir um Jogo da Velha completo no terminal usando Rust puro, sem dependências externas. O jogo terá um tabuleiro colorido renderizado com caracteres ANSI, modo para dois jogadores humanos e um oponente de inteligência artificial que usa o algoritmo minimax – tornando-o imbatível. Ao longo do projeto, você vai praticar arrays, enums, pattern matching, recursão e leitura de entrada do usuário.

O jogo da velha é um projeto clássico para aprender programação, e implementá-lo em Rust é uma ótima forma de entender o sistema de tipos, o tratamento de erros e a manipulação de dados na stack sem alocações desnecessárias.

O Que Vamos Construir

Um jogo da velha com as seguintes funcionalidades:

  • Tabuleiro 3x3 renderizado no terminal com cores ANSI
  • Modo dois jogadores (humano vs humano)
  • Modo contra a IA (humano vs computador) com algoritmo minimax
  • Validação de entrada do usuário (posição ocupada, fora do tabuleiro)
  • Detecção automática de vitória, empate e fim de jogo
  • Indicação visual do jogador atual e do vencedor
  • Opção de jogar novamente sem reiniciar o programa

Estrutura do Projeto

jogo-da-velha/
├── Cargo.toml
└── src/
    └── main.rs

Configurando o Projeto

Crie o projeto com o Cargo:

cargo new jogo-da-velha
cd jogo-da-velha

O Cargo.toml permanece simples, sem dependências externas:

[package]
name = "jogo-da-velha"
version = "0.1.0"
edition = "2021"

Usamos apenas a biblioteca padrão de Rust. As cores no terminal são produzidas com sequências de escape ANSI diretamente.

Passo 1: Definindo os Tipos e o Tabuleiro

Vamos começar definindo os tipos fundamentais do jogo. Usamos enums para representar cada célula do tabuleiro e o resultado da partida.

use std::io::{self, Write};

/// Representa o conteúdo de uma célula do tabuleiro
#[derive(Clone, Copy, PartialEq, Debug)]
enum Celula {
    Vazia,
    X,
    O,
}

/// Resultado possível de uma partida
#[derive(PartialEq, Debug)]
enum Resultado {
    VitoriaX,
    VitoriaO,
    Empate,
    EmAndamento,
}

/// Modo de jogo selecionado pelo usuário
#[derive(PartialEq)]
enum ModoJogo {
    DoisJogadores,
    ContraIA,
}

/// O tabuleiro é um array fixo de 9 células (3x3)
type Tabuleiro = [Celula; 9];

/// Cria um tabuleiro vazio
fn criar_tabuleiro() -> Tabuleiro {
    [Celula::Vazia; 9]
}

Usamos [Celula; 9] em vez de Vec<Celula> porque o tabuleiro tem tamanho fixo e conhecido em tempo de compilação. Isso evita alocação no heap e permite cópia eficiente com Clone + Copy, fundamental para o algoritmo minimax que precisa simular muitos estados.

Passo 2: Renderizando o Tabuleiro e Lendo Entrada

Agora vamos criar as funções para exibir o tabuleiro com cores e ler a jogada do usuário.

/// Converte uma célula em seu caractere com cor ANSI
fn celula_para_string(celula: &Celula, posicao: usize) -> String {
    match celula {
        Celula::X => "\x1b[1;34mX\x1b[0m".to_string(),     // X em azul
        Celula::O => "\x1b[1;31mO\x1b[0m".to_string(),     // O em vermelho
        Celula::Vazia => format!("\x1b[2m{}\x1b[0m", posicao + 1), // Número em cinza
    }
}

/// Exibe o tabuleiro formatado no terminal
fn exibir_tabuleiro(tabuleiro: &Tabuleiro) {
    println!();
    for linha in 0..3 {
        let base = linha * 3;
        println!(
            "  {} | {} | {}",
            celula_para_string(&tabuleiro[base], base),
            celula_para_string(&tabuleiro[base + 1], base + 1),
            celula_para_string(&tabuleiro[base + 2], base + 2),
        );
        if linha < 2 {
            println!("  ---------");
        }
    }
    println!();
}

/// Lê uma jogada válida do jogador humano
fn ler_jogada(tabuleiro: &Tabuleiro, jogador: Celula) -> usize {
    let simbolo = match jogador {
        Celula::X => "X",
        Celula::O => "O",
        Celula::Vazia => unreachable!(),
    };

    loop {
        print!("Jogador {} — escolha uma posição (1-9): ", simbolo);
        io::stdout().flush().unwrap();

        let mut entrada = String::new();
        io::stdin().read_line(&mut entrada).unwrap();

        match entrada.trim().parse::<usize>() {
            Ok(pos) if pos >= 1 && pos <= 9 => {
                let indice = pos - 1;
                if tabuleiro[indice] == Celula::Vazia {
                    return indice;
                }
                println!("\x1b[33mPosição {} já está ocupada. Tente outra.\x1b[0m", pos);
            }
            _ => {
                println!("\x1b[33mEntrada inválida. Digite um número de 1 a 9.\x1b[0m");
            }
        }
    }
}

As posições são numeradas de 1 a 9 para o usuário, mas internamente usamos índices de 0 a 8. Células vazias mostram o número da posição em cinza como referência para o jogador.

Passo 3: Lógica de Vitória e o Algoritmo Minimax

Aqui está o coração do jogo: a detecção de vitória e a inteligência artificial com minimax.

/// Todas as combinações possíveis de vitória (linhas, colunas, diagonais)
const COMBINACOES_VITORIA: [[usize; 3]; 8] = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8], // linhas
    [0, 3, 6], [1, 4, 7], [2, 5, 8], // colunas
    [0, 4, 8], [2, 4, 6],            // diagonais
];

/// Verifica o resultado atual do jogo
fn verificar_resultado(tabuleiro: &Tabuleiro) -> Resultado {
    for combo in &COMBINACOES_VITORIA {
        let [a, b, c] = *combo;
        if tabuleiro[a] != Celula::Vazia
            && tabuleiro[a] == tabuleiro[b]
            && tabuleiro[b] == tabuleiro[c]
        {
            return match tabuleiro[a] {
                Celula::X => Resultado::VitoriaX,
                Celula::O => Resultado::VitoriaO,
                Celula::Vazia => unreachable!(),
            };
        }
    }

    // Se não há vencedor mas todas as células estão preenchidas, é empate
    if tabuleiro.iter().all(|c| *c != Celula::Vazia) {
        Resultado::Empate
    } else {
        Resultado::EmAndamento
    }
}

/// Algoritmo minimax: retorna a pontuação da melhor jogada possível
/// Para o maximizador (IA), retorna a pontuação mais alta
/// Para o minimizador (humano), retorna a pontuação mais baixa
fn minimax(tabuleiro: &mut Tabuleiro, eh_maximizador: bool, profundidade: i32) -> i32 {
    match verificar_resultado(tabuleiro) {
        Resultado::VitoriaO => return 10 - profundidade,  // IA vence (O)
        Resultado::VitoriaX => return profundidade - 10,  // Humano vence (X)
        Resultado::Empate => return 0,
        Resultado::EmAndamento => {}
    }

    let jogador_atual = if eh_maximizador { Celula::O } else { Celula::X };

    if eh_maximizador {
        let mut melhor_pontuacao = i32::MIN;
        for i in 0..9 {
            if tabuleiro[i] == Celula::Vazia {
                tabuleiro[i] = jogador_atual;
                let pontuacao = minimax(tabuleiro, false, profundidade + 1);
                tabuleiro[i] = Celula::Vazia;
                melhor_pontuacao = melhor_pontuacao.max(pontuacao);
            }
        }
        melhor_pontuacao
    } else {
        let mut melhor_pontuacao = i32::MAX;
        for i in 0..9 {
            if tabuleiro[i] == Celula::Vazia {
                tabuleiro[i] = jogador_atual;
                let pontuacao = minimax(tabuleiro, true, profundidade + 1);
                tabuleiro[i] = Celula::Vazia;
                melhor_pontuacao = melhor_pontuacao.min(pontuacao);
            }
        }
        melhor_pontuacao
    }
}

/// Encontra a melhor jogada para a IA usando minimax
fn melhor_jogada_ia(tabuleiro: &mut Tabuleiro) -> usize {
    let mut melhor_pontuacao = i32::MIN;
    let mut melhor_posicao = 0;

    for i in 0..9 {
        if tabuleiro[i] == Celula::Vazia {
            tabuleiro[i] = Celula::O;
            let pontuacao = minimax(tabuleiro, false, 0);
            tabuleiro[i] = Celula::Vazia;

            if pontuacao > melhor_pontuacao {
                melhor_pontuacao = pontuacao;
                melhor_posicao = i;
            }
        }
    }

    melhor_posicao
}

O algoritmo minimax explora recursivamente todas as possibilidades futuras do jogo. A IA (O) é o maximizador, buscando a pontuação mais alta, enquanto o humano (X) é o minimizador. O parâmetro profundidade faz a IA preferir vitórias rápidas e atrasar derrotas. Com o tabuleiro 3x3 (máximo 9! = 362.880 estados), a busca completa é instantânea.

Passo 4: Loop Principal e main.rs Completo

Agora vamos juntar tudo no loop principal do jogo.

/// Exibe o menu e retorna o modo de jogo selecionado
fn selecionar_modo() -> ModoJogo {
    println!("\x1b[1;36m=== JOGO DA VELHA EM RUST ===\x1b[0m");
    println!();
    println!("  1. Dois jogadores (humano vs humano)");
    println!("  2. Contra a IA (humano vs computador)");
    println!();

    loop {
        print!("Escolha o modo (1 ou 2): ");
        io::stdout().flush().unwrap();

        let mut entrada = String::new();
        io::stdin().read_line(&mut entrada).unwrap();

        match entrada.trim() {
            "1" => return ModoJogo::DoisJogadores,
            "2" => return ModoJogo::ContraIA,
            _ => println!("\x1b[33mOpção inválida. Digite 1 ou 2.\x1b[0m"),
        }
    }
}

/// Pergunta se o jogador quer jogar novamente
fn jogar_novamente() -> bool {
    print!("Jogar novamente? (s/n): ");
    io::stdout().flush().unwrap();

    let mut entrada = String::new();
    io::stdin().read_line(&mut entrada).unwrap();

    matches!(entrada.trim().to_lowercase().as_str(), "s" | "sim")
}

/// Executa uma partida completa
fn jogar_partida(modo: &ModoJogo) {
    let mut tabuleiro = criar_tabuleiro();
    let mut jogador_atual = Celula::X; // X sempre começa

    loop {
        exibir_tabuleiro(&tabuleiro);

        let posicao = if *modo == ModoJogo::ContraIA && jogador_atual == Celula::O {
            // Vez da IA
            println!("IA está pensando...");
            let jogada = melhor_jogada_ia(&mut tabuleiro);
            println!("IA jogou na posição {}.", jogada + 1);
            jogada
        } else {
            // Vez do jogador humano
            ler_jogada(&tabuleiro, jogador_atual)
        };

        tabuleiro[posicao] = jogador_atual;

        match verificar_resultado(&tabuleiro) {
            Resultado::VitoriaX => {
                exibir_tabuleiro(&tabuleiro);
                println!("\x1b[1;34mJogador X venceu! Parabéns!\x1b[0m");
                return;
            }
            Resultado::VitoriaO => {
                exibir_tabuleiro(&tabuleiro);
                if *modo == ModoJogo::ContraIA {
                    println!("\x1b[1;31mA IA venceu! Tente novamente.\x1b[0m");
                } else {
                    println!("\x1b[1;31mJogador O venceu! Parabéns!\x1b[0m");
                }
                return;
            }
            Resultado::Empate => {
                exibir_tabuleiro(&tabuleiro);
                println!("\x1b[1;33mEmpate! Boa partida.\x1b[0m");
                return;
            }
            Resultado::EmAndamento => {
                // Alterna o jogador
                jogador_atual = match jogador_atual {
                    Celula::X => Celula::O,
                    Celula::O => Celula::X,
                    Celula::Vazia => unreachable!(),
                };
            }
        }
    }
}

fn main() {
    let modo = selecionar_modo();

    loop {
        jogar_partida(&modo);
        if !jogar_novamente() {
            println!("Obrigado por jogar! Até a próxima.");
            break;
        }
        println!();
    }
}

O loop principal alterna entre os jogadores, verifica o resultado após cada jogada e exibe a mensagem apropriada. No modo IA, quando é a vez de O, a função melhor_jogada_ia calcula automaticamente a melhor posição.

Como Executar

Compile e execute o projeto:

cargo run

Saída esperada:

=== JOGO DA VELHA EM RUST ===

  1. Dois jogadores (humano vs humano)
  2. Contra a IA (humano vs computador)

Escolha o modo (1 ou 2): 2

  1 | 2 | 3
  ---------
  4 | 5 | 6
  ---------
  7 | 8 | 9

Jogador X — escolha uma posição (1-9): 5

  1 | 2 | 3
  ---------
  4 | X | 6
  ---------
  7 | 8 | 9

IA está pensando...
IA jogou na posição 1.

  O | 2 | 3
  ---------
  4 | X | 6
  ---------
  7 | 8 | 9

Jogador X — escolha uma posição (1-9): _

Para compilar em modo release (otimizado):

cargo build --release
./target/release/jogo-da-velha

Desafios para Expandir

  1. Tabuleiro NxN – Generalize o jogo para tabuleiros de qualquer tamanho (4x4, 5x5, etc.), ajustando as condições de vitória e o algoritmo minimax com poda alfa-beta para manter a performance.

  2. Níveis de dificuldade – Adicione dificuldades Fácil (jogadas aleatórias), Médio (minimax com profundidade limitada) e Difícil (minimax completo) para tornar o jogo acessível a todos.

  3. Placar persistente – Salve o histórico de vitórias, derrotas e empates em um arquivo JSON e exiba o placar acumulado entre sessões.

  4. Interface com crossterm – Use a crate crossterm para criar uma interface mais rica com cursor navegável, seleção de posição com setas do teclado e animações de vitória.

  5. Modo rede local – Implemente jogo multiplayer via TCP usando std::net::TcpListener e TcpStream, permitindo que dois jogadores joguem em terminais diferentes na mesma rede.

Veja Também