- C
- raylib
- 3D
- Games
AstroBeat
3D rhythm endless-runner game, developed in C with the raylib library.
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
-
Debug scattered through the final code. The game prints the judgement for every note with
printf(PERFECT,GREAT,MISS,LONG COMPLETA,PAUSADO em: %.4fand severalfflushcalls), 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. -
Heavy global state and fixed limits.
gameStateis global, andarray_notas[1000]withtotal_de_notasareexternshared 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. -
Enum cast and missing validation in the map. To read the type with
%dI do(int*)&array_notas[i].tipo, which depends on the enum beingint, and I do not validate lane, type or the ordering of times while reading. A line with an invalidlaneortipoonly shows up later (drawing ignores lanes outside 0 to 2). -
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. -
Text configuration at the root.
configuracoes.cfgandhighscores.cfgare written to the current working directory; high score parsing uses%99[^=]=%dand compares the name against the song list, which is fragile if the file is edited by hand.
Improvement points
- Remove the debug
printfcalls and theKEY_Fshortcut, or isolate them behind a#define DEBUG. - Validate the beatmap on read (lane, type, increasing times, duration) and warn when
MAX_NOTASis exceeded, or allocate dynamically. - Write settings and high scores to a standard directory (e.g.
~/.astrobeat) instead of the executable’s root. - Reduce coupling through globals by passing state as parameters and isolating the judgement logic so it can be tested.
Gabriel Rocha