Jogo da Cobrinha (Snake) na Unity Passo a Passo
Para fazer o jogo da cobrinha na Unity (o clássico unity snake game), esqueça física: Snake é um jogo de grade que anda em passos fixos. A cobra é uma lista de coordenadas inteiras (Vector2Int), cada passo adiciona uma cabeça e tira o rabo, e colisão é comparar coordenadas. Aqui você monta o jogo inteiro em C# do Unity 6.3 LTS: tick fixo, corpo em List<Vector2Int>, buffer de direção para a cobra nunca virar 180 graus, comida sempre em célula livre, game over, pontuação e velocidade crescente.
Por que Snake é bom primeiro projeto
Snake é pequeno o bastante para terminar num dia e completo o bastante para ensinar o que importa:
- Estado separado do visual. A cobra "de verdade" é uma lista de números. Os quadrados na tela só desenham essa lista. Separar modelo e visualização é a base de quase todo jogo.
- Tempo discreto. O jogo não anda a cada frame, anda a cada passo. Entender a diferença entre frame e tick é o que permite fazer jogos de turno, puzzles e roguelikes depois.
- Estruturas de dados. Lista para o corpo, fila para o input, conjunto para busca rápida. Nada de engine, só lógica, o tipo de raciocínio do post de lógica de programação para jogos.
E a lição mais valiosa: nem todo jogo precisa de Rigidbody. Saber quando não usar a física da engine é tão importante quanto saber usar.
Grade e tick: mover em passos
A grade é um retângulo de width por height células. A célula (0, 0) é o canto inferior esquerdo. Cada segmento da cobra ocupa exatamente uma célula, sempre com coordenadas inteiras.
O tick é o relógio do jogo. Em vez de mover a cobra no Update com Time.deltaTime (que dá posições quebradas e colisões imprecisas), você acumula o tempo e executa um passo inteiro quando o acumulado passa do intervalo:
tickTimer += Time.deltaTimea cada frame.- Enquanto
tickTimer >= tickInterval: subtrai o intervalo e executaTick().
O while em vez de if garante que, num frame lento, o jogo execute os passos atrasados em vez de perder tempo. Com intervalo de 0,15 s, a cobra anda cerca de 6,7 células por segundo em qualquer computador, rodando a 30 ou a 240 quadros.
Jogo da cobrinha na Unity: cabeça e corpo
Prepare a cena:
- Crie o projeto com o template Universal 2D.
- Crie um sprite quadrado branco (Create > 2D > Sprites > Square no Project) e um prefab
Segmentcom ele, cor verde, escala 0,9 (o espaço entre quadrados deixa a cobra legível). - Crie um GameObject
Foodcom o mesmo sprite em vermelho. - Na Main Camera, Size 8 enxerga uma grade de 20x15 com folga.
- Crie um texto TextMeshPro para a pontuação (GameObject > UI > Text - TextMeshPro) e um painel de game over desativado.
Agora o script inteiro, em SnakeGame.cs:
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
public class SnakeGame : MonoBehaviour
{
[Header("Grade")]
[SerializeField] private int width = 20;
[SerializeField] private int height = 15;
[SerializeField] private float cellSize = 1f;
[Header("Ritmo")]
[SerializeField] private float startTickInterval = 0.15f;
[SerializeField] private float minTickInterval = 0.06f;
[SerializeField] private float speedUpPerFood = 0.004f;
[Header("Visual")]
[SerializeField] private Transform segmentPrefab;
[SerializeField] private Transform food;
[SerializeField] private TMP_Text scoreText;
[SerializeField] private GameObject gameOverPanel;
private const int MaxBufferedInputs = 2;
private readonly List<Vector2Int> body = new List<Vector2Int>();
private readonly List<Transform> segmentViews = new List<Transform>();
private readonly List<Vector2Int> inputBuffer = new List<Vector2Int>();
private Vector2Int direction = Vector2Int.right;
private Vector2Int foodCell;
private float tickInterval;
private float tickTimer;
private int score;
private bool isGameOver;
private void Start()
{
Vector2Int start = new Vector2Int(width / 2, height / 2);
body.Add(start);
body.Add(start + Vector2Int.left);
body.Add(start + Vector2Int.left * 2);
tickInterval = startTickInterval;
gameOverPanel.SetActive(false);
PlaceFood();
UpdateScore();
Render();
}
private void Update()
{
Keyboard keyboard = Keyboard.current;
if (keyboard == null)
{
return;
}
if (isGameOver)
{
if (keyboard.spaceKey.wasPressedThisFrame)
{
SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);
}
return;
}
if (keyboard.upArrowKey.wasPressedThisFrame || keyboard.wKey.wasPressedThisFrame) QueueDirection(Vector2Int.up);
if (keyboard.downArrowKey.wasPressedThisFrame || keyboard.sKey.wasPressedThisFrame) QueueDirection(Vector2Int.down);
if (keyboard.leftArrowKey.wasPressedThisFrame || keyboard.aKey.wasPressedThisFrame) QueueDirection(Vector2Int.left);
if (keyboard.rightArrowKey.wasPressedThisFrame || keyboard.dKey.wasPressedThisFrame) QueueDirection(Vector2Int.right);
tickTimer += Time.deltaTime;
while (tickTimer >= tickInterval && !isGameOver)
{
tickTimer -= tickInterval;
Tick();
}
}
private void QueueDirection(Vector2Int newDirection)
{
if (inputBuffer.Count >= MaxBufferedInputs)
{
return;
}
Vector2Int last = inputBuffer.Count > 0 ? inputBuffer[inputBuffer.Count - 1] : direction;
bool same = newDirection == last;
bool opposite = newDirection + last == Vector2Int.zero;
if (same || opposite)
{
return;
}
inputBuffer.Add(newDirection);
}
private void Tick()
{
if (inputBuffer.Count > 0)
{
direction = inputBuffer[0];
inputBuffer.RemoveAt(0);
}
Vector2Int nextHead = body[0] + direction;
bool outside = nextHead.x < 0 || nextHead.x >= width || nextHead.y < 0 || nextHead.y >= height;
if (outside)
{
GameOver();
return;
}
bool ate = nextHead == foodCell;
int cellsToCheck = ate ? body.Count : body.Count - 1;
for (int i = 0; i < cellsToCheck; i++)
{
if (body[i] == nextHead)
{
GameOver();
return;
}
}
body.Insert(0, nextHead);
if (ate)
{
score++;
tickInterval = Mathf.Max(minTickInterval, tickInterval - speedUpPerFood);
UpdateScore();
if (!PlaceFood())
{
GameOver();
return;
}
}
else
{
body.RemoveAt(body.Count - 1);
}
Render();
}
private bool PlaceFood()
{
HashSet<Vector2Int> occupied = new HashSet<Vector2Int>(body);
List<Vector2Int> freeCells = new List<Vector2Int>();
for (int x = 0; x < width; x++)
{
for (int y = 0; y < height; y++)
{
Vector2Int cell = new Vector2Int(x, y);
if (!occupied.Contains(cell))
{
freeCells.Add(cell);
}
}
}
if (freeCells.Count == 0)
{
return false;
}
foodCell = freeCells[Random.Range(0, freeCells.Count)];
food.position = GridToWorld(foodCell);
return true;
}
private void Render()
{
while (segmentViews.Count < body.Count)
{
segmentViews.Add(Instantiate(segmentPrefab, transform));
}
for (int i = 0; i < segmentViews.Count; i++)
{
bool used = i < body.Count;
segmentViews[i].gameObject.SetActive(used);
if (used)
{
segmentViews[i].position = GridToWorld(body[i]);
}
}
}
private Vector3 GridToWorld(Vector2Int cell)
{
float x = (cell.x - (width - 1) * 0.5f) * cellSize;
float y = (cell.y - (height - 1) * 0.5f) * cellSize;
return new Vector3(x, y, 0f);
}
private void UpdateScore()
{
scoreText.SetText("Pontos: {0}", score);
}
private void GameOver()
{
isGameOver = true;
gameOverPanel.SetActive(true);
}
}
O corpo é List<Vector2Int> com a cabeça no índice 0. Cada passo faz duas operações: Insert(0, novaCabeça) e RemoveAt(último). A cobra "anda" sem mover nenhum segmento do meio: só nasce uma cabeça na frente e some o rabo atrás. Quando come, o rabo não é removido naquele passo, e a cobra cresce uma célula. Simples assim.
GridToWorld converte coordenada da grade para posição na cena, centralizando a grade na origem. É a única ponte entre a lógica e o visual. A lógica nunca lê transform.position; o visual nunca decide nada.
Input com buffer de direção
Este é o bug que separa o Snake de primeiro dia do Snake bem feito. Sem buffer, o código costuma fazer direction = novaDireção direto no aperto, com uma checagem para não aceitar o oposto. O problema: a cobra está indo para a direita, e o jogador aperta cima e esquerda muito rápido, dentro do mesmo passo. Cima é aceito (não é oposto de direita). Esquerda também é aceito (não é oposto de cima). No próximo tick, a cobra vira para a esquerda, em cima do próprio pescoço, e morre sem o jogador ter feito nada errado.
O buffer resolve:
- Cada aperto entra numa fila (
inputBuffer) de no máximo 2 direções. - A checagem de oposto e de repetida é feita contra a última direção da fila, não contra a direção atual.
- Cada tick consome uma direção da fila.
No exemplo, cima entra na fila; esquerda é comparada com cima (não é oposta) e entra também. Tick 1: a cobra vira para cima. Tick 2: vira para a esquerda. O movimento em "U" rápido funciona, que é exatamente o que o jogador quis fazer. A comparação de oposto usa newDirection + last == Vector2Int.zero: duas direções opostas somam zero.
Com o jogo funcionando e o controle à prova de dedo rápido, você chega à parte que nenhum tutorial de mecânica isolada cobre: transformar isso num jogo publicável, com menu, sons, transição, recorde, build para a web e página no itch.io. É justamente esse caminho do protótipo ao jogo terminado que um curso de Unity com projetos completos faz você percorrer, várias vezes, até virar hábito.
Comida em posição livre
PlaceFood monta a lista de células livres e sorteia uma. O HashSet<Vector2Int> com o corpo deixa a pergunta "esta célula está ocupada?" instantânea, mesmo com a cobra grande.
A alternativa ingênua (sortear qualquer célula e tentar de novo se cair na cobra) funciona com a cobra pequena. Com a cobra ocupando 95% da grade, ela tenta dezenas de vezes por comida, e com 100% entra em laço infinito e congela o jogo. A lista de livres sempre termina. Quando não há célula livre, a grade está cheia: o jogador venceu, e o código encerra a partida chamando o mesmo GameOver(). Se quiser uma tela de vitória, troque essa chamada por um painel próprio.
Crescer, colidir e game over
A colisão é por coordenada, em duas checagens antes de mover:
- Parede: a próxima cabeça está fora de
0..width-1ou0..height-1. - Corpo: a próxima cabeça é igual a algum segmento.
O detalhe fino: se a cobra não vai comer neste passo, o último segmento vai sair do lugar. Então a cabeça pode entrar na célula onde o rabo está agora, e o código exclui o rabo da checagem (body.Count - 1). Sem esse cuidado, a cobra morre ao perseguir o próprio rabo em círculo fechado, o que no Snake clássico é um movimento válido.
Para uma variação sem paredes, troque o game over da borda por "dar a volta": nextHead.x = (nextHead.x + width) % width, e o mesmo para Y.
Pontuação e velocidade
Cada comida soma 1 ponto e diminui o intervalo do tick em 0,004 s, até o mínimo de 0,06 s. A cobra começa a cerca de 6,7 passos por segundo e chega a mais de 16 no limite. A curva é lenta de propósito: o jogador sente o jogo acelerando sem perceber o momento exato em que ficou difícil.
Para salvar o recorde, use PlayerPrefs.GetInt e PlayerPrefs.SetInt no game over, com PlayerPrefs.Save() em seguida.
Polimento que cabe em uma hora
O jogo funciona, mas três detalhes visuais mudam muito a sensação:
- Cabeça diferente do corpo. No
Render, pintesegmentViews[0]com um verde mais claro (pegue oSpriteRenderere mude acolor) e gire a cabeça para a direção atual. O jogador precisa saber num relance para onde a cobra vai. - Movimento suave entre células. A lógica continua em passos, mas o visual pode interpolar. Guarde a posição anterior de cada segmento e, no
Update, useVector3.Lerpentre a anterior e a atual comtickTimer / tickIntervalcomo fator. A cobra desliza em vez de pular, sem mudar nada na regra do jogo. - Pausa curta ao comer. Dois ou três centésimos de segundo de congelamento quando a cobra come, mais um som curto, deixam cada ponto com peso.
Repare que nenhuma dessas mudanças toca a lógica da lista. É a vantagem de separar estado e visual: dá para trocar o visual inteiro (pixel art, 3D, neon) sem medo de quebrar o jogo.
Erros comuns e variações
A cobra anda em velocidades diferentes em PCs diferentes. O movimento está no Update sem tick. Use o acumulador.
A cobra atravessa o próprio corpo. A colisão está sendo checada depois de mover, ou contra posições de transform em vez de coordenadas da lista.
A comida nasce em cima da cobra. O sorteio não consulta o corpo. Use a lista de células livres.
A cobra morre ao virar rápido. Falta o buffer de direção.
Variações para praticar: paredes internas como obstáculos, comida dourada que vale 5 e some em 3 segundos, modo de dois jogadores no mesmo teclado (WASD e setas) e uma cobra inimiga controlada por IA. Para movimento em grade aplicado a outros tipos de jogo (puzzle, tático, roguelike), veja movimento em grade na Unity. E se você também usa Godot, o jogo da cobrinha no Godot mostra a mesma lógica em GDScript, o que prova que o difícil aqui é o raciocínio, não a engine.
Perguntas frequentes
Precisa de física para fazer Snake na Unity?
Não. Snake é um jogo de grade: a posição de cada segmento é uma coordenada inteira (Vector2Int) e a colisão é comparar coordenadas. Usar Rigidbody2D e colliders só adiciona imprecisão e bugs a um jogo que é pura lógica.
Por que a cobrinha morre ao apertar duas teclas rápido?
Porque as duas mudanças de direção caem no mesmo passo e a segunda vira a cabeça 180 graus, para cima do próprio corpo. A correção é um buffer de direção: cada aperto entra numa fila, a fila rejeita a direção oposta à última aceita e cada passo consome só uma direção.
Como sortear a comida sem cair em cima da cobra?
Monte a lista de todas as células livres (as que não estão no corpo) e sorteie uma delas. Sortear qualquer célula e tentar de novo quando cai na cobra funciona no começo, mas fica lento e pode travar quando a cobra ocupa quase a grade toda.
Snake é um bom primeiro jogo na Unity?
É um dos melhores: tem regra clara, fim de partida, pontuação e dificuldade crescente, e treina lógica de programação pura (listas, coordenadas, filas) sem depender de física ou animação.



