Como Fazer Jogo de Ritmo: Sincronia de Áudio no Godot 4

Como fazer jogo de ritmo no Godot 4: sincronia real entre áudio e gameplay, BPM e beats, spawn de notas, janela de acerto e calibração de latência.
Como Fazer Jogo de Ritmo: Sincronia de Áudio no Godot 4
Quando alguém me pergunta qual projeto pequeno fazer depois de aprender o básico da engine, jogo de ritmo está sempre na minha lista curta. E quando a pessoa pesquisa como fazer jogo de ritmo, quase sempre esbarra no mesmo buraco: o protótipo funciona, as notas descem, mas alguma coisa parece fora da batida e ninguém sabe explicar o quê. Esse "quê" tem nome, tem causa e tem uma solução canônica documentada no manual do Godot 4. É exatamente isso que vamos montar aqui, com GDScript tipado do começo ao fim.
O plano: entender por que o gênero é um ótimo projeto de escopo pequeno, encarar o único problema técnico sério dele (a sincronia entre áudio e gameplay), construir um Conductor que sabe a posição real da música, transformar a música em dados com um chart, spawnar notas, julgar o input com janelas de acerto e fechar com feedback e calibração de latência.
Por que jogo de ritmo é o projeto pequeno perfeito
O gênero tem uma propriedade rara: uma única mecânica. O jogador aperta um botão no tempo certo. Só isso. Não existe inventário, não existe IA de inimigo, não existe sistema de diálogo. Você poli essa mecânica até ela ficar gostosa e o jogo está de pé.
E o conteúdo? O conteúdo é a música. Cada faixa nova é uma fase nova sem escrever uma linha de código a mais. Isso inverte a matemática cruel dos projetos indie: em um RPG, cada hora de jogo custa semanas de sistemas e conteúdo autoral; em um jogo de ritmo, cada fase custa uma música e um chart. É o motivo de tanta joia indie do gênero ter saído de equipes minúsculas.
Mas existe um pedágio, e ele é inegociável. Jogo de ritmo vive e morre pela precisão temporal. Um jogo de plataforma sobrevive a 50 milissegundos de imprecisão sem ninguém notar. Um jogo de ritmo com 50 milissegundos de erro sistemático parece quebrado, mesmo que o jogador não consiga apontar o motivo. Então antes de desenhar nota bonita, você precisa resolver o relógio.
O problema: o relógio do jogo mente pra você
Todo iniciante monta a primeira versão assim: dá play na música, acumula delta em uma variável a cada frame e usa esse acumulado como "posição da música". Ou pior, usa um Timer disparando no intervalo do BPM. Parece razoável. Está errado, e está errado de um jeito que só aparece com o jogo rodando.
O motivo: existem dois relógios na sua máquina. O relógio do jogo avança frame a frame, ao sabor do que a CPU e a GPU deixarem. O relógio do áudio corre no hardware de som, alimentado em blocos (os mixes), com buffer próprio e latência própria. Eles não são o mesmo relógio e não correm exatamente na mesma velocidade. A diferença é minúscula por segundo, mas ela acumula. Chama-se drift: aos 30 segundos de música seu contador de delta já está alguns milissegundos fora; aos 2 minutos, o suficiente pra toda nota parecer atrasada ou adiantada.
Tem mais um detalhe sujo: um frame engasgado não atrasa a música (o áudio segue tocando no hardware), mas atrasa o seu acumulador de delta. Ou seja, além de driftar aos poucos, o erro dá saltos.
A regra número um do gênero, portanto: nunca use Timer nem delta acumulado pra saber onde a música está. Pergunte ao sistema de áudio.
Como fazer jogo de ritmo que não desafina: o relógio do áudio
O manual oficial do Godot tem uma seção dedicada a isso, chamada "Sync the gameplay with audio and music", e a receita cabe em três chamadas:
AudioStreamPlayer.get_playback_position()devolve a posição de playback, mas ela avança em degraus, atualizada a cada bloco de mix, não continuamente.AudioServer.get_time_since_last_mix()devolve quanto tempo passou desde o último mix, o que suaviza esses degraus.AudioServer.get_output_latency()devolve a latência de saída do dispositivo, que você subtrai porque o que o jogador ouve chega esse tanto depois do que o Godot processou.
Centralize isso em um autoload. Crie uma cena com um Node raiz chamado Conductor, um AudioStreamPlayer filho, e registre em Project Settings como singleton:
# conductor.gd
# Autoload: a unica fonte de verdade sobre a posicao da musica.
extends Node
@onready var player: AudioStreamPlayer = $AudioStreamPlayer
@export var bpm: float = 120.0
# Silencio entre o inicio do arquivo e a primeira batida real da musica.
@export var offset_primeira_batida: float = 0.0
var offset_jogador: float = 0.0 # calibracao manual, salva nas opcoes
var segundos_por_beat: float = 0.5
var latencia: float = 0.0
func _ready() -> void:
segundos_por_beat = 60.0 / bpm
# get_output_latency() pode ser custoso em algumas plataformas.
# O proprio manual recomenda cachear o valor.
latencia = AudioServer.get_output_latency()
func tocar() -> void:
player.play()
func posicao_da_musica() -> float:
# A formula canonica do manual do Godot 4:
var tempo: float = player.get_playback_position()
tempo += AudioServer.get_time_since_last_mix()
tempo -= latencia
return tempo - offset_primeira_batida + offset_jogador
func beat_atual() -> float:
return posicao_da_musica() / segundos_por_beat
Três conceitos desse script merecem nome próprio, porque todo o resto do jogo se apoia neles.
BPM (batidas por minuto) define o pulso da música. A 120 BPM, cada beat dura 60.0 / 120.0 = 0.5 segundos. Essa conta, 60.0 / bpm, é a constante mais importante do seu jogo.
Beat é a unidade de tempo do gameplay. Em vez de pensar "a nota cai aos 13.75 segundos", você pensa "a nota cai no beat 27.5". Converter é dividir: posicao_em_segundos / (60.0 / bpm). Trabalhar em beats deixa o chart legível e independente do arquivo de áudio.
Offset da primeira batida existe porque quase nenhum arquivo de música começa com a batida no instante zero. Tem um respiro, um fade-in, um acorde solto. Abra a faixa num editor de áudio (Audacity resolve), meça onde cai o primeiro beat de verdade e coloque esse valor no offset_primeira_batida. Errar isso desloca o jogo inteiro.
Com o Conductor de pé, ninguém mais no projeto chama get_playback_position() direto. Todo mundo pergunta Conductor.beat_atual() e pronto: uma fonte de verdade, zero drift.
O chart: transformando a música em dados
Chart é o mapa da fase: a lista de momentos em que o jogador deve acertar uma nota. Em produção você vai querer um editor próprio, mas pra aprender o gênero um Resource com um Array de beats resolve por completo:
# chart.gd
class_name Chart
extends Resource
# Tempos de acerto em BEATS, nao em segundos.
# Ex.: [4.0, 5.0, 6.0, 6.5, 7.0, 8.0]
@export var notas: Array[float] = []
Salve um chart_musica_01.tres, preencha o Array no Inspector ouvindo a música e contando as batidas. Vinte ou trinta notas bastam pro protótipo.
Agora o spawn. A nota não pode nascer no momento do acerto: ela precisa nascer antes, pra viajar pela tela e chegar na zona de hit exatamente no beat alvo. Escolha uma antecedência fixa em beats (4 é um bom começo) e spawne quando o beat atual cruzar essa marca:
# spawner.gd
extends Node2D
const BEATS_DE_ANTECEDENCIA: float = 4.0
@export var chart: Chart
@export var cena_nota: PackedScene
var proxima: int = 0 # indice da proxima nota do chart
func _process(_delta: float) -> void:
var beat: float = Conductor.beat_atual()
while proxima < chart.notas.size() \
and chart.notas[proxima] - BEATS_DE_ANTECEDENCIA <= beat:
var nota: Nota = cena_nota.instantiate() as Nota
nota.beat_alvo = chart.notas[proxima]
add_child(nota)
proxima += 1
E aqui vem o segundo erro clássico do gênero: mover a nota com velocidade vezes delta. Se você fizer isso, reintroduziu o drift que acabou de expulsar. A posição da nota tem que ser derivada do relógio do áudio a cada frame, nunca acumulada:
# nota.gd
class_name Nota
extends Node2D
var beat_alvo: float = 0.0
const PIXELS_POR_BEAT: float = 150.0
const Y_DA_ZONA_DE_HIT: float = 550.0
func _process(_delta: float) -> void:
# Posicao calculada a partir do Conductor, nunca acumulada.
var beats_restantes: float = beat_alvo - Conductor.beat_atual()
position.y = Y_DA_ZONA_DE_HIT - beats_restantes * PIXELS_POR_BEAT
Repare no que isso compra de graça: se o jogo engasgar um frame, a nota não fica pra trás, ela se teletransporta pro lugar certo no frame seguinte, porque a posição dela é uma função pura do tempo da música. Frame perdido vira soluço visual de um instante, não dessincronia permanente.
A janela de acerto: perfeito, bom, errou
O julgamento do input é uma comparação de dois números: o tempo em que o jogador apertou e o tempo alvo da nota mais próxima. A diferença absoluta entre eles cai numa janela, e a janela define o resultado:
# julgamento.gd
extends Node
# Janelas em segundos. PONTO DE PARTIDA: calibre com playtest.
const JANELA_PERFEITO: float = 0.05
const JANELA_BOM: float = 0.12
const JANELA_ERRO: float = 0.20
var combo: int = 0
func _unhandled_input(event: InputEvent) -> void:
if not event.is_action_pressed("bater"):
return
var agora: float = Conductor.posicao_da_musica()
var nota: Nota = nota_mais_proxima(agora)
if nota == null:
return
var alvo: float = nota.beat_alvo * Conductor.segundos_por_beat
var erro: float = absf(agora - alvo)
if erro <= JANELA_PERFEITO:
registrar_acerto(nota, "perfeito")
elif erro <= JANELA_BOM:
registrar_acerto(nota, "bom")
elif erro <= JANELA_ERRO:
registrar_erro(nota)
# Acima de JANELA_ERRO: ignora, o aperto nao era pra essa nota.
A função nota_mais_proxima() percorre as notas vivas na tela e devolve a de menor absf(agora - alvo). Com poucas notas simultâneas, um loop simples resolve; não otimize antes da hora.
Sobre os valores: 0.05s pro perfeito e 0.12s pro bom são um ponto de partida honesto, na faixa do que jogos do gênero praticam, mas não são lei. Janela apertada demais frustra iniciante; larga demais tira a graça. A única forma de acertar é colocar gente pra jogar e ajustar. Também repare na terceira faixa: um aperto muito longe de qualquer nota não deve contar como erro dela, senão o jogador quebra o combo espremendo botão à toa.
Um detalhe de projeto que poupa dor: julgue sempre em segundos, não em pixels. A distância visual da nota até a zona de hit depende de resolução e velocidade de rolagem; o tempo, não.
Feedback e calibração: onde o jogo passa a ser gostoso
Com a sincronia e o julgamento certos, o que separa um protótipo seco de algo viciante é feedback. Três camadas baratas que transformam a sensação:
Som de hit. Um clique ou snap curto tocando no acerto dá ao jogador confirmação auditiva instantânea. Toque num AudioStreamPlayer dedicado, roteado num bus separado da música: o guia de bus de áudio e mixagem no Godot mostra como organizar isso sem bagunça.
Flash e escala. No perfeito, pisque a zona de hit e dê um pulso de escala na nota (um tween de 0.1s basta). Julgamentos diferentes merecem cores diferentes: o jogador aprende a ler a própria precisão sem olhar pra números.
Combo. Um contador que cresce a cada acerto e zera no erro é o gancho psicológico mais barato do gênero. Mostre grande, anime a quebra. Se quiser ir além, escalar a intensidade da própria trilha com o combo é um casamento natural com áudio dinâmico e música adaptativa em jogos.
E falta a peça que todo jogo de ritmo sério tem: calibração pro jogador. A latência varia com fone bluetooth, TV com pós-processamento, driver de áudio. Você já viu o campo offset_jogador no Conductor; agora dê um jeito do jogador preencher ele. A tela clássica: um metrônomo visual e sonoro em BPM fixo, o jogador aperta no que ele percebe como a batida, você mede o erro médio de 8 ou 16 apertos e salva o resultado como offset. Vinte linhas de código que separam "parece quebrado no meu fone" de "funciona em qualquer setup".
Se o seu som de hit, o metrônomo e os efeitos estiverem mal escolhidos, nada disso brilha: vale a leitura sobre audio design para jogos pra escolher e tratar esses sons direito.
Por onde começar hoje
Roteiro concreto pro primeiro protótipo, na ordem:
- Escolha uma música de uns 60 segundos com batida bem marcada e descubra o BPM dela.
- Monte o Conductor como autoload e imprima
beat_atual()na tela. Bata palma junto: se o número vira no tempo da batida, a fundação está certa. - Meça o offset da primeira batida no Audacity e preencha.
- Escreva um chart de 20 notas no Inspector, ouvindo e contando.
- Spawner, nota derivada do relógio, julgamento com as janelas de 0.05 e 0.12.
- Som de hit, flash, combo. Só então, a tela de calibração.
Cada passo é testável isoladamente, e o passo 2 é o que salva o projeto: valide o relógio antes de construir qualquer coisa em cima dele.
Jogo de ritmo é isso: um problema técnico central resolvido com três chamadas de API e uma disciplina (nunca confiar no delta), cercado de conteúdo que é música e polish que é feedback. Escopo pequeno, aprendizado enorme, e um gênero onde um dev sozinho compete de igual pra igual. Escolhe a música e começa.
Perguntas frequentes
Como sincronizar a música com o gameplay no Godot?
Nunca acumule delta nem use Timer: pergunte a posição real ao sistema de áudio. A fórmula do manual oficial do Godot é get_playback_position() do AudioStreamPlayer somada a AudioServer.get_time_since_last_mix() e subtraída de AudioServer.get_output_latency(). Centralize esse cálculo em um autoload e faça todo o resto do jogo ler dele.
É difícil fazer um jogo de ritmo?
A mecânica em si é das mais simples que existem: uma nota viaja até uma zona de acerto, o jogador aperta um botão e você compara dois números. A única parte traiçoeira é a sincronia entre áudio e gameplay, que tem solução pronta e documentada no Godot 4. Se você já domina o básico de GDScript, um protótipo jogável sai em um fim de semana.
Como fazer a janela de acerto de um jogo de ritmo?
Compare o tempo do input com o tempo alvo da nota mais próxima e classifique pela diferença absoluta: até uns 0.05s é perfeito, até uns 0.12s é bom, acima disso é erro. Esses valores são ponto de partida, não lei: calibre com playtest no seu jogo e no seu hardware. Ofereça também um offset configurável pro jogador compensar a latência do setup dele.
Qual engine é boa pra jogo de ritmo?
O Godot 4 resolve muito bem: a API de áudio expõe exatamente o que o gênero precisa (posição de playback, tempo desde o último mix e latência de saída), e o manual oficial documenta a técnica de sincronia. Unity também funciona, via AudioSettings.dspTime. O que decide o resultado não é a engine, é usar o relógio do áudio em vez do relógio do jogo.
Por que não usar Timer ou delta pra acompanhar a música?
Porque o relógio do jogo e o relógio do áudio correm em lugares diferentes e driftam alguns milissegundos por segundo. Em um jogo comum ninguém percebe, mas num jogo de ritmo o erro acumula: depois de um minuto de música, as notas chegam visivelmente fora da batida. A posição precisa vir do próprio sistema de áudio, sempre.


