Voltar para o Blog
Quest Log

Como fazer o jogo da cobrinha na Godot 4 passo a passo

Jogo da cobrinha feito na Godot mostrando a cobra verde em blocos sobre um fundo escuro, com a comida vermelha e o placar no canto da tela

Aprenda a fazer o jogo da cobrinha na Godot 4 passo a passo: grid, movimento por timer, comida, crescimento, placar e game over em GDScript tipado.

O jogo da cobrinha é o "olá, mundo" dos jogos de grid: cabe em um script, não precisa de arte nem de física, e mesmo assim ensina conceitos que você vai reaproveitar em quase todo projeto 2D. Neste tutorial você vai montar o jogo da cobrinha completo na Godot 4, com GDScript tipado do início ao fim: a cobra andando em passos por um Timer, comida em posição aleatória, crescimento ao comer, placar e game over com reinício. Se você já abriu a Godot uma vez e sabe criar uma cena, tem tudo que precisa para acompanhar.

Se GDScript ainda parece um bicho de sete cabeças, vale dar uma passada no guia de GDScript do zero antes de continuar. Aqui vou explicar cada bloco, mas assumo que você reconhece uma variável e uma função quando as vê. E se você veio do Scratch, vai notar que a lógica é a mesma do Snake feito no Scratch, só que agora escrita em código de verdade.

A lógica do jogo da cobrinha: um grid, não pixels

Antes de qualquer código, entenda a decisão que simplifica tudo: a cobrinha não vive em pixels, vive em células. Vamos trabalhar com um grid de 20 por 20 células, e cada posição do jogo é um Vector2i, um vetor de números inteiros. A cabeça da cobra pode estar em Vector2i(9, 10), a comida em Vector2i(3, 7). Só na hora de desenhar é que convertemos célula para pixel, multiplicando pela largura da célula.

Isso muda a natureza de todos os problemas. Colisão com a parede? Compare a coordenada com os limites do grid. Colisão com o próprio corpo? Verifique se a coordenada já existe no Array de segmentos. Nada de áreas, corpos físicos ou sinais de colisão. É o mesmo raciocínio de estado em grid que usamos no tutorial de Tetris na Godot, e dominar isso aqui torna aquele projeto o próximo passo natural.

A cobra inteira será um Array[Vector2i]: o primeiro elemento é a cabeça e o resto é o corpo, em ordem. Mover é adicionar uma célula nova na frente e tirar a última. Crescer é só pular a segunda parte. Guarde essa frase, ela é o coração do jogo.

Preparando o projeto e a cena

Crie um projeto novo na Godot 4. Em Project, Project Settings, aba Input Map, adicione quatro ações: move_up, move_down, move_left e move_right, e associe as setas do teclado (e WASD, se quiser) a cada uma. Usar ações em vez de teclas fixas deixa o controle configurável de graça.

Agora a cena. Crie uma cena com um Node2D na raiz, renomeie para Main e adicione três filhos:

  • Um Node2D chamado Cells, que vai servir de contêiner para os quadradinhos desenhados a cada passo.
  • Um Timer chamado MoveTimer, que dita o ritmo do jogo.
  • Um Label chamado ScoreLabel, posicionado no canto superior esquerdo, para o placar.

Com o grid de 20x20 e células de 32 pixels, o campo ocupa 640 por 640 pixels. Ajuste o tamanho da janela em Project Settings, Display, Window, para 640 por 640, e o jogo preenche a tela certinho. Salve a cena como main.tscn, anexe um script main.gd ao nó Main e vamos ao código.

Constantes, variáveis e o estado do jogo

Todo o estado do jogo cabe em meia dúzia de variáveis. Repare que tudo é tipado: int, Vector2i, Array[Vector2i]. A tipagem estática avisa erros no editor antes de você rodar e é um hábito que vale criar desde o primeiro projeto.

extends Node2D

const CELL_SIZE: int = 32
const GRID_WIDTH: int = 20
const GRID_HEIGHT: int = 20

var snake: Array[Vector2i] = []
var direction: Vector2i = Vector2i.RIGHT
var next_direction: Vector2i = Vector2i.RIGHT
var food_position: Vector2i = Vector2i.ZERO
var score: int = 0

@onready var cells: Node2D = $Cells
@onready var move_timer: Timer = $MoveTimer
@onready var score_label: Label = $ScoreLabel

Duas variáveis de direção parecem redundantes, mas não são. direction é para onde a cobra está andando de fato. next_direction é para onde o jogador pediu para virar. Separar as duas resolve dois bugs clássicos da cobrinha, e já chegamos neles.

No _ready, conectamos o sinal timeout do Timer e iniciamos a partida:

func _ready() -> void:
    move_timer.wait_time = 0.15
    move_timer.timeout.connect(_on_move_timer_timeout)
    start_game()


func start_game() -> void:
    snake = [Vector2i(9, 10), Vector2i(8, 10), Vector2i(7, 10)]
    direction = Vector2i.RIGHT
    next_direction = Vector2i.RIGHT
    score = 0
    score_label.text = "Pontos: 0"
    spawn_food()
    move_timer.start()
    draw_board()

A cobra começa com três segmentos no meio do campo, apontando para a direita. O wait_time de 0.15 segundo significa que a cobra dá um passo a cada 0.15 segundo, pouco mais de 6 passos por segundo. É um bom ritmo inicial: rápido o bastante para ter graça, lento o bastante para reagir.

Por que Timer e não _process

Aqui está a decisão de arquitetura mais importante do projeto. Um iniciante tende a mover tudo dentro de _process, somando speed * delta na posição. Isso é correto para movimento contínuo, como um personagem de plataforma. A cobrinha é diferente: ela anda em passos discretos, uma célula inteira por vez, em ritmo constante.

O nó Timer modela isso perfeitamente. Ele emite o sinal timeout a cada wait_time segundos, e é só nesse momento que o mundo avança. Entre um passo e outro, nada se move. De bônus, subir a dificuldade mais tarde vira uma linha: diminuir o wait_time.

O _process continua tendo um papel: ler o teclado. Input precisa ser capturado a cada frame, senão o jogador aperta a seta entre dois passos do Timer e o toque se perde.

func _process(_delta: float) -> void:
    if Input.is_action_just_pressed("move_up") and direction != Vector2i.DOWN:
        next_direction = Vector2i.UP
    elif Input.is_action_just_pressed("move_down") and direction != Vector2i.UP:
        next_direction = Vector2i.DOWN
    elif Input.is_action_just_pressed("move_left") and direction != Vector2i.RIGHT:
        next_direction = Vector2i.LEFT
    elif Input.is_action_just_pressed("move_right") and direction != Vector2i.LEFT:
        next_direction = Vector2i.RIGHT

    if move_timer.is_stopped() and Input.is_action_just_pressed("ui_accept"):
        get_tree().reload_current_scene()

Repare na trava de direção reversa: se a cobra anda para baixo, o pedido de virar para cima é ignorado, porque direction != Vector2i.DOWN falha. Sem essa trava, virar 180 graus faria a cabeça entrar no próprio pescoço e morrer na hora, o que é frustrante e injusto.

E repare que a comparação usa direction, a direção real do passo atual, e não next_direction. Se comparasse com next_direction, o jogador poderia apertar duas setas dentro do mesmo passo (esquerda e depois baixo, por exemplo) e a segunda viraria um 180 disfarçado. Comparando com direction, só a primeira virada válida de cada passo conta de verdade.

A última parte do _process cuida do reinício: quando o Timer está parado (ou seja, deu game over), apertar Enter ou espaço recarrega a cena inteira com get_tree().reload_current_scene(), que zera tudo e chama o _ready de novo.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

O passo da cobra: o coração do jogo

Tudo que acontece no jogo acontece dentro do callback do Timer. Leia com calma, porque estas poucas linhas são o jogo inteiro:

func _on_move_timer_timeout() -> void:
    direction = next_direction
    var new_head: Vector2i = snake[0] + direction

    if is_out_of_bounds(new_head) or snake.has(new_head):
        game_over()
        return

    snake.push_front(new_head)

    if new_head == food_position:
        score += 10
        score_label.text = "Pontos: %d" % score
        spawn_food()
    else:
        snake.pop_back()

    draw_board()


func is_out_of_bounds(cell: Vector2i) -> bool:
    return cell.x < 0 or cell.x >= GRID_WIDTH or cell.y < 0 or cell.y >= GRID_HEIGHT

A sequência do passo:

  1. A direção pedida pelo jogador vira a direção oficial.
  2. Calculamos a nova cabeça somando a direção à cabeça atual. Como tudo é Vector2i, a soma já dá a célula vizinha correta.
  3. Se a nova cabeça saiu do grid ou caiu em uma célula que o corpo ocupa (snake.has(new_head)), é game over e o passo termina ali.
  4. push_front coloca a nova cabeça no início do Array.
  5. Se a cabeça caiu na comida, somamos 10 pontos, atualizamos o Label e sorteamos comida nova. E, de propósito, não removemos a cauda: o Array fica um elemento maior, e é exatamente assim que a cobra cresce.
  6. Se não comeu, pop_back remove o último segmento, e a cobra apenas andou, mantendo o tamanho.

O crescimento sair de graça é a elegância da estrutura de dados bem escolhida. Nenhuma variável de tamanho, nenhum contador: crescer é literalmente deixar de encolher por um passo.

Comida em célula aleatória, mas nunca em cima da cobra

Sortear uma célula é fácil com randi_range, que devolve um inteiro entre os dois limites, inclusive. O detalhe que separa um jogo correto de um jogo bugado é garantir que a comida não nasça dentro do corpo da cobra:

func spawn_food() -> void:
    var new_position: Vector2i = Vector2i.ZERO
    while true:
        new_position = Vector2i(
            randi_range(0, GRID_WIDTH - 1),
            randi_range(0, GRID_HEIGHT - 1)
        )
        if not snake.has(new_position):
            break
    food_position = new_position

O laço sorteia até encontrar uma célula livre. No começo da partida, com 3 segmentos em 400 células, ele quase sempre acerta de primeira. Em partidas longas, com a cobra gigante, pode repetir algumas vezes, mas continua instantâneo para qualquer tamanho de cobra que um humano consiga alcançar.

Desenhando o campo com ColorRect

Para um protótipo, quadrados coloridos resolvem, e o ColorRect é o nó perfeito para isso: um retângulo de cor sólida, sem textura. A estratégia é simples e um pouco bruta: a cada passo, apagamos todos os quadrados do contêiner Cells e criamos tudo de novo.

func draw_board() -> void:
    for child: Node in cells.get_children():
        child.queue_free()

    for segment: Vector2i in snake:
        add_cell(segment, Color(0.2, 0.7, 0.3))

    add_cell(snake[0], Color(0.4, 1.0, 0.5))
    add_cell(food_position, Color(0.9, 0.2, 0.2))


func add_cell(cell: Vector2i, color: Color) -> void:
    var rect: ColorRect = ColorRect.new()
    rect.color = color
    rect.size = Vector2(CELL_SIZE, CELL_SIZE)
    rect.position = Vector2(cell * CELL_SIZE)
    cells.add_child(rect)

O queue_free marca cada nó para ser liberado com segurança no fim do frame, que é a forma certa de destruir nós na Godot. A função add_cell faz a única conversão de grid para pixel do projeto: multiplica a célula pelo CELL_SIZE e usa o resultado como posição em pixels. O corpo é desenhado em verde escuro, a cabeça é redesenhada por cima em verde claro para o jogador se localizar, e a comida em vermelho.

Recriar nós a cada passo não é a técnica mais eficiente do mundo, mas para 400 células no máximo, rodando 6 vezes por segundo, a Godot nem percebe. Quando o jogo estiver funcionando, migrar para um TileMapLayer ou para sprites é um exercício ótimo, e a lógica do grid não muda nada.

Game over e reinício

Falta o final da partida, que já deixamos encaminhado:

func game_over() -> void:
    move_timer.stop()
    score_label.text = "Game over! Pontos: %d. Aperte Enter para reiniciar." % score

Parar o Timer congela o jogo, porque todo o movimento vive no timeout. O Label vira a tela de game over improvisada. E como o _process já verifica move_timer.is_stopped() junto com ui_accept, o Enter recarrega a cena e uma partida nova começa limpa. reload_current_scene é o botão de reset perfeito para jogos pequenos: destrói tudo e reconstrói a cena do zero, sem risco de estado esquecido da partida anterior.

Rode a cena com F6 (ou defina como cena principal e aperte F5). Se tudo estiver no lugar, a cobra anda sozinha para a direita, obedece as setas, cresce ao comer, e morre na parede ou no próprio corpo. Você tem um jogo completo, com começo, meio, fim e placar, em cerca de 100 linhas tipadas.

Próximos passos para deixar o jogo seu

O clone funcional é o ponto de partida, não a linha de chegada. Três upgrades na ordem que eu recomendo:

Sons. Adicione um AudioStreamPlayer para o som de comer e outro para o game over, e chame play() nos pontos certos do código. Dois efeitos sonoros mudam completamente a sensação do jogo.

Wrap nas bordas. Em vez de morrer na parede, a cobra pode atravessar e reaparecer do outro lado, no estilo dos celulares antigos. Troque o teste de is_out_of_bounds por uma correção da posição: some GRID_WIDTH ao x quando ficar negativo e use o operador % para dar a volta. A colisão com o corpo continua igual.

Dificuldade progressiva. A cada comida, acelere o jogo um pouco: reduza move_timer.wait_time multiplicando por 0.97, com um piso em torno de 0.06 para continuar jogável. Dez linhas e o jogo ganha tensão crescente de arcade.

Quando enjoar da cobrinha, o caminho natural é encarar um clone com mais estado e mais regras. O tutorial de Tetris citado lá em cima usa as mesmas ferramentas, grid de inteiros, Timer e desenho manual, com uma camada a mais de desafio na rotação das peças. Entre um e outro, você terá construído a base de lógica que sustenta a maior parte dos jogos 2D. Bom código e boa cobra.

Perguntas frequentes

Por que usar um Timer em vez de mover a cobra no _process?

Porque a cobrinha anda em passos discretos, uma célula por vez, e não de forma contínua. O _process roda a cada frame e serve para movimento suave. Com um Timer você controla exatamente quantos passos acontecem por segundo, e acelerar o jogo vira só diminuir o wait_time.

Preciso de física ou CharacterBody2D para fazer a cobrinha na Godot?

Não. O jogo inteiro vive em um grid lógico de Vector2i. Colisão é uma comparação de coordenadas: se a nova cabeça está fora do grid ou dentro do Array da cobra, é game over. Usar corpos físicos aqui só adicionaria complexidade sem nenhum ganho.

Como impedir que a cobra vire 180 graus e morra sozinha?

Guarde a direção desejada em uma variável separada, next_direction, e só aceite a mudança se ela não for o oposto da direção atual. A direção real só é atualizada no passo do Timer, o que também evita o bug de duas viradas dentro do mesmo passo.

Por que a comida às vezes nasceria em cima da cobra e como evitar?

randi_range sorteia qualquer célula do grid, incluindo as ocupadas pelo corpo. A solução é sortear dentro de um laço e repetir o sorteio enquanto a posição estiver contida no Array da cobra. Com um grid de 20x20 e uma cobra pequena, o laço resolve em uma ou duas tentativas.

Como faço a cobra crescer quando come?

O truque é o movimento em duas operações: adicionar a nova cabeça na frente do Array e remover a cauda no final. Quando a cabeça cai na comida, você simplesmente não remove a cauda naquele passo. O Array fica um segmento maior e a cobra cresce sem nenhum código extra.