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
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.
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.
Placar persistente – Salve o histórico de vitórias, derrotas e empates em um arquivo JSON e exiba o placar acumulado entre sessões.
Interface com crossterm – Use a crate
crosstermpara criar uma interface mais rica com cursor navegável, seleção de posição com setas do teclado e animações de vitória.Modo rede local – Implemente jogo multiplayer via TCP usando
std::net::TcpListenereTcpStream, permitindo que dois jogadores joguem em terminais diferentes na mesma rede.
Veja Também
- Arrays na Biblioteca Padrão – Como funcionam arrays de tamanho fixo em Rust
- Option para Valores Opcionais – Tratamento de valores que podem ou não existir
- Entrada e Saída Padrão – Leitura de stdin e escrita em stdout
- Lendo Input do Usuário – Receita prática para ler entrada no terminal
- Rust para Jogos – Panorama do ecossistema de jogos em Rust