The most useful thing you can add is a map. Build one in the in-game editor,
then open a pull request with the song folder — without the audio unless
you own it or the author has said yes in writing. A map.json on its own is
fine: players point it at their own copy of the track.
Read docs/MAP_FORMAT.md first.
The project is plain Godot 4 + GDScript. No plugins, no C#.
scripts/
├── G.gd autoload: settings, save file, palette, UI helpers
├── Loc.gd autoload: runtime translations
├── Conductor.gd autoload: the musical clock
├── UICursor.gd autoload: the game's own cursor
├── Main.gd screen state machine
├── *Screen.gd one file per screen
├── core/ Judge, RhythmMap, MapImport, Replay, Achievements, Binds
├── editor/ EditorTimeline (piano roll), EditorHistory (undo)
└── playfield/ note/frame/effect views, Melly's rig
House style, matching what is already there:
- Tabs for indentation,
snake_casefor functions and variables,PascalCasefor classes. - Static types wherever they are not noise (
var x := 0.0). - Comments in English, and only where the why is not obvious from the
code. Docstring (
##) at the top of every file saying what it is for. - No new autoloads without a good reason.
- UI strings go through the existing English literals —
Loc.gdtranslates them by source string, so adding a string means adding a key to the five language tables there if you want it translated.
scripts/Loc.gd is generated - edit tools/gen_loc.py instead. Every
string is one add() call with the English source and the five translations,
and the script rewrites Loc.gd:
python3 tools/gen_loc.pyIt also prints every UI string it found in the scripts that has no entry yet, so coverage cannot rot quietly. Keys are the English source strings, so Godot translates any Control automatically and untranslated keys fall back to English on their own.
To add a language, add it to LANGS in the script and give every add() call
one more argument.
tools/charting.py turns a list of musical events into the three difficulties.
It works in phrases - a movement pattern every two bars, seeded per song - so no
two tracks end up with the same shape, and it places holds first because a hold
owns the cursor for its whole length.
Where those events come from is the important part. tools/beatgrid.py
measures the tempo and the position of the first beat from the audio, and
tools/rechart.py puts a note only where there is an attack to hear:
python3 tools/beatgrid.py --selftest # prove the detector's own timing first
python3 tools/rechart.py --all --dry # measure every song, write nothing
python3 tools/rechart.py --all # rebuild the difficulties
python3 tools/beatgrid.py --check # fail if a chart drifted off its musicNever take the BPM in map.json on trust when rebuilding a chart. An earlier
version of the re-charter did exactly that, and for the songs whose declared
tempo was wrong it snapped every note onto a grid the music does not follow -
which the player hears as the hitsound tapping somewhere between the beats,
getting worse the further into the song they get. The tempo is measured now,
and --check keeps it that way: every difficulty has to sit within 15 ms of
the attacks in its own audio.
The detector reports onsets about 43 ms before the sound that caused them,
because the analysis window reaches back in time. That constant is measured by
--selftest against a click track at known times and taken out of everything
beatgrid reports. If you change the window or the hop, run the self-test and
re-measure it, or every chart in the library will look 43 ms out of time.
Never put a comment in project.godot. A # line inside a section is
folded into the name of the key beneath it, the key vanishes, and its default
quietly takes over - which is how VR, the engine's log file and the Android
back button were all broken at once by three tidy explanatory comments. The
explanations live in docs/PROJECT_SETTINGS.md
instead, and CoreTest fails if any setting name comes back with a comment in
it.
There is a small headless test harness. CI runs the first one on every push.
# save file round-trip, hold notes, judging, modifiers, conductor offset
godot --headless --path . res://tools/CoreTest.tscn
# import a map from another game and print what came out
godot --headless --path . res://tools/ImportTest.tscn -- /path/to/map.sspmThe versus handshake needs two processes:
OR_NET=host godot --headless --path . res://tools/NetTest.tscn # one terminal
OR_NET=join godot --headless --path . res://tools/NetTest.tscn # anotherAdd OR_NET_BAD=1 to the host to check that a mismatched chart is refused
rather than played.
CoreTest exits non-zero when a check fails, and prints one PASS/FAIL
line per check. Add to tools/test_core.gd when you touch saving, judging or
the map format.
Note that the tests write to user://, so run them with the same Godot build
you play with or they will land in a different sandbox.
- Run
CoreTest, then play through the screens you touched. - Keep
export_presets.cfgclean: no keystore paths, no passwords. CI rejects a commit that carries them. - Do not commit anything under
build/,.godot/, or a song folder with third-party audio.
Say which platform and build you were on, what the song folder looked like if it is a library or import problem, and attach the map when you can.