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.

Ferramenta:Godot

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. Se você quer focar só nas duas técnicas de tolerância, tem uma versão enxuta em coyote time e jump buffer no Godot que vai direto ao ponto. Se quiser ampliar o repertório de movimento depois, o agachar (crouch) em 2D é outra peça clássica do moveset de plataforma.

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: float = ProjectSettings.get_setting("physics/2d/default_gravity")

func _physics_process(delta: float) -> void:
    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: float) -> void:
    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 (e o mesmo raciocínio de mexer direto na velocity serve pra forças externas do cenário: uma mola ou trampolim é só um impulso bem maior que o do pulo normal):

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

func _physics_process(delta: float) -> void:
    # ... 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.

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot 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 Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 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: float) -> void:
    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: float) -> void:
    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: float) -> void:
    # 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.

Estendendo: pulo duplo sem quebrar o coyote time

Assim que o pulo base fica gostoso, a pergunta seguinte quase sempre é o pulo duplo. A tentação é adicionar um if solto em algum lugar, e é aí que o controlador começa a brigar consigo mesmo: o pulo do ar conflita com o coyote time e, de repente, o jogador ganha três pulos de graça perto da borda. A forma limpa é tratar os pulos como um estoque que recarrega no chão.

A ideia é um contador de pulos disponíveis. No chão, ele volta ao máximo. Cada pulo gasta uma unidade. O primeiro pulo continua passando pela regra do coyote time; os pulos do ar não, porque pular no ar é justamente a intenção.

const MAX_JUMPS: int = 2  # 1 = só pulo normal, 2 = pulo duplo

var jumps_left: int = 0

func _physics_process(delta: float) -> void:
    # ... gravidade e timers acima

    # Recarrega o estoque de pulos ao tocar o chão.
    if is_on_floor():
        jumps_left = MAX_JUMPS

    # Primeiro pulo: respeita coyote + buffer, como antes.
    if jump_buffer_timer > 0.0 and coyote_timer > 0.0 and jumps_left == MAX_JUMPS:
        velocity.y = jump_velocity
        jumps_left -= 1
        jump_buffer_timer = 0.0
        coyote_timer = 0.0
    # Pulos do ar: só consomem o estoque, sem checar coyote.
    elif jump_buffer_timer > 0.0 and jumps_left > 0 and jumps_left < MAX_JUMPS:
        velocity.y = jump_velocity
        jumps_left -= 1
        jump_buffer_timer = 0.0

    # ... corte de pulo e movimento horizontal abaixo

O detalhe que evita o bug de pulo triplo é o jumps_left == MAX_JUMPS na primeira condição: ela só dispara quando o estoque está cheio, ou seja, no primeiro pulo. Os pulos do ar caem no elif, que exige estoque menor que o máximo. Assim o coyote time nunca libera um pulo extra, ele só decide se o primeiro pulo ainda vale depois da borda. Se você quiser que o pulo do ar também aceite buffer, ele já aceita, porque continua lendo o mesmo jump_buffer_timer.

A partir daqui o moveset abre em leque. O pulo de parede entra na mesma lógica de estoque, só que recarregando ao tocar uma parede em vez do chão, e vale montar junto com o pulo duplo seguindo o tutorial de double jump e wall jump no Godot. E um dash de personagem 2D é o próximo verbo de movimento mais comum, que também combina com tolerância de input pra não punir o jogador por milissegundos.

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.