Voltar para o Blog
Quest Log

Como Programar um Pulo de Jogo de Plataforma com Coyote Time e Jump Buffer

Personagem de plataforma saltando de uma borda com trajetória de pulo desenhada no ar

Aprenda a programar um pulo de jogo de plataforma que responde bem: coyote time, jump buffer e gravidade variável com código GDScript pronto no Godot 4.

Como Programar um Pulo de Jogo de Plataforma com Coyote Time e Jump Buffer

O pulo de jogo de plataforma é a mecânica mais testada do seu jogo inteiro. O jogador vai apertar esse botão milhares de vezes, e cada aperto é um veredito: ou o controle responde do jeito que ele esperava, ou ele sente que o jogo "roubou". A boa notícia é que a distância entre um pulo travado e um pulo gostoso não é talento místico de designer. São quatro técnicas concretas, cada uma com menos de quinze linhas de código: gravidade variável, altura controlável, coyote time e jump buffer.

Nesse tutorial eu monto as quatro em GDScript no Godot 4, explicando o porquê de cada uma. No final tem o controlador completo, pronto pra colar num CharacterBody2D e ajustar pro seu jogo.

O que separa um pulo bom de um pulo de jogo de plataforma genérico

Pega o Celeste, o Hollow Knight ou qualquer Mario e observa o pulo quadro a quadro. Nenhum deles usa física "realista". Todos mentem a favor do jogador:

  • O personagem sobe mais devagar do que cai. Queda rápida dá peso e devolve o controle logo.
  • Segurar o botão pula alto, tocar de leve dá um pulinho. O jogador dosa a altura.
  • Dá pra pular alguns frames depois de sair da borda da plataforma (coyote time).
  • Apertar pulo um instante antes de pousar ainda conta (jump buffer).

As duas últimas existem por um motivo simples: humano erra timing. Numa tela a 60 FPS, a janela de um frame dura uns 16 milissegundos. Exigir precisão de frame do jogador transforma seu jogo num teste de reflexo involuntário. As técnicas abaixo perdoam o erro pequeno sem tirar a habilidade do jogo.

O pulo base (e por que ele parece ruim)

Esse é o pulo que todo tutorial de Godot ensina primeiro:

extends CharacterBody2D

const SPEED = 300.0
const JUMP_VELOCITY = -400.0

var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta):
    if not is_on_floor():
        velocity.y += gravity * delta

    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = JUMP_VELOCITY

    velocity.x = Input.get_axis("move_left", "move_right") * SPEED
    move_and_slide()

Funciona, mas tem três problemas. A trajetória é uma parábola simétrica, então o personagem flutua na descida com a mesma lentidão da subida. A altura é fixa, apertou, pulou tudo. E o is_on_floor() no pulo é implacável: um pixel fora da plataforma e o input morre. É exatamente isso que vamos consertar, peça por peça.

Antes de seguir, crie as ações jump, move_left e move_right no Input Map (Project Settings > Input Map). Se preferir testar rápido, troque pelos nomes prontos ui_accept, ui_left e ui_right.

Gravidade variável: subir num ritmo, cair em outro

Em vez de chutar valores de gravidade e velocidade até parecer certo, dá pra fazer o contrário: você decide a altura do pulo e a duração da subida e da descida, e calcula a física a partir disso. É cinemática de colégio aplicada a game design:

  • Velocidade inicial do pulo: v = 2h / t_subida
  • Gravidade na subida: g = 2h / t_subida²
  • Gravidade na queda: g = 2h / t_descida²

Onde h é a altura do pulo em pixels e t é o tempo em segundos. Com tempos diferentes pra subida e descida, você ganha a queda mais rápida de graça, sem nenhum if mágico:

const JUMP_HEIGHT = 64.0          # altura do pulo em pixels
const JUMP_TIME_TO_PEAK = 0.35    # segundos até o topo
const JUMP_TIME_TO_DESCENT = 0.28 # segundos do topo até o chão

# Negativo porque no 2D do Godot o eixo Y cresce pra baixo.
var jump_velocity: float = (-2.0 * JUMP_HEIGHT) / JUMP_TIME_TO_PEAK
var jump_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_PEAK * JUMP_TIME_TO_PEAK)
var fall_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_DESCENT * JUMP_TIME_TO_DESCENT)

func get_gravity_for_state() -> float:
    # Subindo (velocity.y negativa) usa uma gravidade, caindo usa outra.
    return jump_gravity if velocity.y < 0.0 else fall_gravity

func _physics_process(delta):
    if not is_on_floor():
        velocity.y += get_gravity_for_state() * delta
    # ... resto do controlador

O ganho prático é enorme na hora de ajustar o jogo. O level designer fala "preciso que o player alcance uma plataforma 64 pixels acima", você muda JUMP_HEIGHT pra 64 e pronto, garantido pela matemática. Nada de testar vinte combinações de gravidade e velocidade no olho.

Repare que com esses valores o pulo ignora a gravidade global do projeto. Pra esse personagem, tudo bem: o pulo é dele, não do mundo.

Altura controlável: o corte de pulo

Segurar o botão pra pular mais alto não exige simular força acumulada. O truque clássico é cortar a velocidade vertical no momento em que o jogador solta o botão durante a subida:

const JUMP_CUT = 0.4  # fração da velocidade que sobra ao soltar cedo

func _physics_process(delta):
    # ... gravidade e pulo acima

    if Input.is_action_just_released("jump") and velocity.y < 0.0:
        velocity.y *= JUMP_CUT

Soltou cedo, a velocidade de subida despenca pra 40% e o personagem desacelera até o topo de um pulo curto. Segurou até o fim, pulo cheio. Duas linhas, e o jogador ganha controle analógico num botão digital.

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

Coyote time: o pulo depois da borda

O nome vem do Coiote do desenho animado, que corre além do penhasco e fica suspenso no ar até olhar pra baixo. No jogo, a ideia é: se o jogador apertou pulo logo depois que o personagem saiu da plataforma, o pulo ainda vale.

Sem isso, acontece uma cena que você já viveu: o jogador corre até a beirada, aperta pulo no que ele jura que era o último pixel do chão, e o personagem despenca. Pra ele, o jogo falhou. Tecnicamente o is_on_floor() já era falso fazia dois frames, mas o jogador não pensa em frames, pensa em intenção.

A implementação é um temporizador que recarrega enquanto há chão e esvazia no ar:

const COYOTE_TIME = 0.1  # segundos de tolerância após sair da borda

var coyote_timer := 0.0

func _physics_process(delta):
    if is_on_floor():
        coyote_timer = COYOTE_TIME
    else:
        coyote_timer -= delta

    # O pulo agora checa o timer, não o chão diretamente.
    if Input.is_action_just_pressed("jump") and coyote_timer > 0.0:
        velocity.y = jump_velocity
        coyote_timer = 0.0  # impede pulo duplo de graça

Zerar o timer depois do pulo é obrigatório. Sem isso, o jogador pula, e nos 0.1 segundos seguintes o timer ainda está positivo, liberando um segundo pulo no ar.

Valores comuns ficam entre 0.05 e 0.15 segundos, ou seja, de 3 a 9 frames a 60 FPS. É imperceptível no olho e gigante na sensação.

Jump buffer: o pulo antes do pouso

O jump buffer é o irmão gêmeo do coyote time, só que no pouso. O jogador está caindo, antecipa o chão e aperta pulo um tiquinho cedo demais. Sem buffer, o input cai no vazio e ele precisa apertar de novo, e o pulo encadeado que ele queria vira uma pausa esquisita.

Com buffer, o jogo guarda a intenção por uma fração de segundo e dispara o pulo assim que o personagem pousa:

const JUMP_BUFFER_TIME = 0.12  # segundos que o input fica guardado

var jump_buffer_timer := 0.0

func _physics_process(delta):
    if Input.is_action_just_pressed("jump"):
        jump_buffer_timer = JUMP_BUFFER_TIME
    else:
        jump_buffer_timer -= delta

    # Pula quando há intenção recente E permissão de pulo.
    if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
        velocity.y = jump_velocity
        jump_buffer_timer = 0.0
        coyote_timer = 0.0

Repare na elegância da condição final: o buffer responde "o jogador quis pular há pouco?" e o coyote responde "o personagem tem direito de pular?". Quando as duas são verdadeiras ao mesmo tempo, pula. Essa única linha cobre o pulo normal, o pulo na borda e o pulo bufferizado, sem nenhum caso especial.

O controlador completo

Juntando tudo, com movimento horizontal com aceleração pra fechar o pacote:

extends CharacterBody2D

const SPEED = 300.0
const ACCELERATION = 2000.0
const FRICTION = 1800.0

const JUMP_HEIGHT = 64.0
const JUMP_TIME_TO_PEAK = 0.35
const JUMP_TIME_TO_DESCENT = 0.28
const JUMP_CUT = 0.4

const COYOTE_TIME = 0.1
const JUMP_BUFFER_TIME = 0.12

var jump_velocity: float = (-2.0 * JUMP_HEIGHT) / JUMP_TIME_TO_PEAK
var jump_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_PEAK * JUMP_TIME_TO_PEAK)
var fall_gravity: float = (2.0 * JUMP_HEIGHT) / (JUMP_TIME_TO_DESCENT * JUMP_TIME_TO_DESCENT)

var coyote_timer := 0.0
var jump_buffer_timer := 0.0

func _physics_process(delta):
    # Gravidade variável: uma na subida, outra na queda.
    if not is_on_floor():
        var g := jump_gravity if velocity.y < 0.0 else fall_gravity
        velocity.y += g * delta

    # Coyote: recarrega no chão, esvazia no ar.
    if is_on_floor():
        coyote_timer = COYOTE_TIME
    else:
        coyote_timer -= delta

    # Buffer: recarrega ao apertar, esvazia com o tempo.
    if Input.is_action_just_pressed("jump"):
        jump_buffer_timer = JUMP_BUFFER_TIME
    else:
        jump_buffer_timer -= delta

    # Pulo: intenção recente + permissão de pulo.
    if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
        velocity.y = jump_velocity
        jump_buffer_timer = 0.0
        coyote_timer = 0.0

    # Altura controlável: soltou cedo, corta a subida.
    if Input.is_action_just_released("jump") and velocity.y < 0.0:
        velocity.y *= JUMP_CUT

    # Movimento horizontal com aceleração e fricção.
    var direction := Input.get_axis("move_left", "move_right")
    if direction:
        velocity.x = move_toward(velocity.x, direction * SPEED, ACCELERATION * delta)
    else:
        velocity.x = move_toward(velocity.x, 0.0, FRICTION * delta)

    move_and_slide()

São menos de 60 linhas e esse controlador já se comporta melhor que muito jogo publicado. A ordem das checagens importa: gravidade primeiro, timers depois, pulo, corte, movimento horizontal e o move_and_slide() fechando o frame.

Tuning: os números são do seu jogo

Nenhum valor acima é sagrado. Eles são um ponto de partida sensato, e o trabalho de verdade é iterar:

  • JUMP_HEIGHT e os tempos de pico: defina a partir do seu level design. Altura de plataforma padrão mais uma folga de uns 10% costuma funcionar bem.
  • JUMP_TIME_TO_DESCENT menor que JUMP_TIME_TO_PEAK: é o que dá peso. Se o personagem parece um balão, encurte a descida.
  • COYOTE_TIME entre 0.05 e 0.15: jogo rápido e punitivo pede menos, jogo casual aceita mais. Acima de 0.15 começa a ficar visível e parece pulo no ar.
  • JUMP_BUFFER_TIME entre 0.1 e 0.15: quanto mais encadeado for o platforming do seu jogo, mais o buffer trabalha.
  • JUMP_CUT entre 0.3 e 0.5: mais baixo dá diferença maior entre pulo curto e cheio.

O método de teste é simples: exponha os valores com @export no script, rode o jogo, mude no Inspector com o jogo rodando e sinta a diferença na hora. Um truque que uso é desligar uma técnica de cada vez depois de tudo pronto. Você só percebe quanto o coyote time carrega o jogo nas costas quando o remove.

Fechando

Pulo gostoso não é acidente nem segredo de estúdio grande. É gravidade assimétrica calculada a partir da altura desejada, corte de velocidade ao soltar o botão, e dois temporizadores de tolerância que perdoam o erro humano de timing. Cada técnica é pequena, e juntas elas mudam completamente como o seu jogo de plataforma se sente na mão.

Implementa as quatro, mas implementa uma de cada vez, testando entre cada etapa. Sentir o antes e o depois de cada técnica vale mais do que qualquer parágrafo desse artigo, e te dá o critério pra ajustar os números pro seu jogo, não pro meu.