Gabriel Rocha
Projects

AstroBeat

3D rhythm endless-runner game, developed in C with the raylib library.

2026Repository

AstroBeat is a 3D rhythm game in the endless-runner style, written in C with raylib. The player pilots a ship along a neon track split into three lanes, dodging obstacles and hitting notes to the beat of the music. Each song has its own beatmap, so the gameplay is synchronized with the audio instead of being generated procedurally.

Structure and state machine

The game is divided into modules by responsibility (main.c, menu.c, notas.c, score.c, player.c, environment.c, buffs.c, audio.c, highscores.c), and main runs a state machine declared in globals.h:

typedef enum {
    MENU, SONG_SELECT, SETTINGS, HIGHSCORES,
    PLAYING, PAUSED, RESULTS, QUIT
} GameState;

The main loop is a while (true) with a large switch that updates and then draws the current state. Two 3D cameras coexist: one for the background scenery (menu) and one for the gameplay.

Synchronization with the music

The heart of the game is the music clock. While playing, game time is the position played by raylib minus the instant the music started:

if (gameState == PLAYING || gameState == PAUSED) {
    tempo_jogo = obterTempoMusica(&audio) - tempo_inicio_musica;
    if (tempo_jogo < 0.0f) tempo_jogo = 0.0f;
}

Notes and obstacles do not use per-frame physics: the 3D position is a function of music time. A note spawns at z = -80 and travels at VEL_NOTAS = 10.0 units per second; the hit instant is when tempo_jogo crosses the note’s tempo (the hit line sits at z ≈ -2, the HIT_OFFSET):

float z = -80.0f + (tempo_atual - (array_notas[i].tempo - TEMPO_ATE_HIT)) * VEL_NOTAS;

Because the position derives from time, drawing is decoupled from the frame rate (the target is 144 FPS), but all the judging now depends on the audio clock.

Maps and notes

Each song in songs/ has a beatmap in mapasMusicas/ and a collectible list in mapasColetaveis/. Reading uses a simple text format, tempo faixa tipo duracao per line:

while (i < 1000 && fscanf(f, "%f %d %d %f",
        &array_notas[i].tempo,
        &array_notas[i].lane,
        (int*)&array_notas[i].tipo,
        &array_notas[i].duracao) == 4) {
    array_notas[i].ativa = 0;
    array_notas[i].resultado = JULG_PENDENTE;
    i++;
}
total_de_notas = i;

Each note has a type (low, high, long, left, right) and a lane (0 to 2). To hit a normal note you must be on its lane (laneAnterior == lane) and press the key corresponding to its type at the right instant; the long note is held with space.

Judging and scoring

Judging uses time windows around the note’s ideal instant, defined as constants:

float JANELA_PERFECT = 0.050f;
float JANELA_GREAT   = 0.075f;
float JANELA_GOOD    = 0.100f;
float JANELA_OK      = 0.125f;
float JANELA_MISS    = 0.150f;

The hit happens when the difference |tempo_atual - nota.tempo| falls within the right window, and each judgement has its own multiplier added to the streak multiplier:

float multiplicador_julgamento(Julgamento nota) {
    case JULG_OK:      return 1.0f;
    case JULG_GOOD:    return 1.25f;
    case JULG_GREAT:   return 1.5f;
    case JULG_PERFECT: return 2.0f;
}

float multiplicador_streak(int streak) {
    if (streak >= 30) return 8.0f;
    if (streak >= 20) return 4.0f;
    if (streak >= 10) return 2.0f;
    return 1.0f;
}

Missing zeroes the streak and resets the multiplier to 1. Long notes are judged in two parts: the head follows the normal window and, while the key is held, every 0.1s adds 10 points per multiplier; releasing before the end counts as a miss. At the end, hits are organized into a matrix by type (low, high, long, movement and overall) with totals, hits, misses and accuracy in %, and the rank (S, A, B, C, D or F) comes from the overall accuracy.

The buffs (collectibles on the tracks) change this logic: the 2x multiplier doubles the points for a while and “window +20ms” widens the judgement windows by 20 ms.

Audio, settings and high scores

audio.c loads the music with raylib, controls play/pause/seek and persists the volume in configuracoes.cfg (text, defaulting to 0.8). highscores.c stores, per song, the highest score in highscores.cfg in the format Nome=pts and only updates the file when the new score beats the previous one. The DataLoader from other projects does not exist here; the game creates the configuration files at runtime.

Particularities and points I would revisit

  1. Debug scattered through the final code. The game prints the judgement for every note with printf (PERFECT, GREAT, MISS, LONG COMPLETA, PAUSADO em: %.4f and several fflush calls), and there is still a debug shortcut that skips the song by 10 seconds (KEY_F, commented as “DEBUG: skip na música”). This should not be in the delivery build.

  2. Heavy global state and fixed limits. gameState is global, and array_notas[1000] with total_de_notas are extern shared across modules. Reading the beatmap stops at 1000 notes without warning, so a song with a larger map would be silently truncated. It works for an academic game, but the coupling makes it harder to test and evolve.

  3. Enum cast and missing validation in the map. To read the type with %d I do (int*)&array_notas[i].tipo, which depends on the enum being int, and I do not validate lane, type or the ordering of times while reading. A line with an invalid lane or tipo only shows up later (drawing ignores lanes outside 0 to 2).

  4. Geometry derived from time (a decision I would keep). Each note’s position is a projection of music time, which keeps the visuals stable regardless of frame. The price is that the entire judgement depends on raylib’s GetMusicTimePlayed; if the audio desynchronizes, it turns into judgement errors.

  5. Text configuration at the root. configuracoes.cfg and highscores.cfg are written to the current working directory; high score parsing uses %99[^=]=%d and compares the name against the song list, which is fragile if the file is edited by hand.

Improvement points