Voltar para o Blog
Quest Log

Jogo da Cobrinha (Snake) na Unity Passo a Passo

Cobrinha verde feita de quadrados numa grade escura indo em direção a uma maçã vermelha

Jogo da cobrinha na Unity em C#: grade com tick fixo, corpo em List de Vector2Int, buffer de direção, comida em célula livre e colisão sem física.

Ferramenta:UnityC#

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.deltaTime a cada frame.
  • Enquanto tickTimer >= tickInterval: subtrai o intervalo e executa Tick().

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:

  1. Crie o projeto com o template Universal 2D.
  2. Crie um sprite quadrado branco (Create > 2D > Sprites > Square no Project) e um prefab Segment com ele, cor verde, escala 0,9 (o espaço entre quadrados deixa a cobra legível).
  3. Crie um GameObject Food com o mesmo sprite em vermelho.
  4. Na Main Camera, Size 8 enxerga uma grade de 20x15 com folga.
  5. 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.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

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:

  1. Parede: a próxima cabeça está fora de 0..width-1 ou 0..height-1.
  2. 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, pinte segmentViews[0] com um verde mais claro (pegue o SpriteRenderer e mude a color) 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, use Vector3.Lerp entre a anterior e a atual com tickTimer / tickInterval como 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.