Voltar para o Blog
Quest Log

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

Notas coloridas descendo por trilhas verticais em direção a uma zona de acerto luminosa, em estilo neon de jogo de ritmo, sem texto.

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:

  1. 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.
  2. AudioServer.get_time_since_last_mix() devolve quanto tempo passou desde o último mix, o que suaviza esses degraus.
  3. 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.

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 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:

  1. Escolha uma música de uns 60 segundos com batida bem marcada e descubra o BPM dela.
  2. 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.
  3. Meça o offset da primeira batida no Audacity e preencha.
  4. Escreva um chart de 20 notas no Inspector, ouvindo e contando.
  5. Spawner, nota derivada do relógio, julgamento com as janelas de 0.05 e 0.12.
  6. 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.