rustic-app runs a minimal state machine built on a single Box<dyn Screen> field. There is no enum of states, no stack, and no transition table. A screen signals that it wants to hand off control by returning Some(next_screen) from next_screen(). The app loop replaces current_screen, passes the audio engine across if needed, calls init() on the new screen, and continues.
The Screen trait
Every screen implements the same interface, defined in crates/rustic-app/src/screen.rs:
take_audio / set_audio pair exists for one specific reason: the freakyMenu background music that plays on the title screen, main menu, and freeplay should not restart on every transition. The current screen hands its AudioEngine to the next one, which adopts it.
How a transition happens
The entire transition logic lives inside theRedrawRequested handler in main.rs:
next_screen() is called once per frame. When it returns None (the common case), no allocation happens.
Screens in the codebase
Thescreens module in crates/rustic-app/src/screens/mod.rs registers each screen as its own sub-module:
main.rs boots directly into TitleScreen:
Screen transition flow
Title screen
The engine starts here.
TitleScreen initialises the GPU, loads the title atlas, and plays freakyMenu. When the player presses Enter (or the title sequence completes), it returns a MainMenuScreen from next_screen().Main menu
MainMenuScreen receives the AudioEngine from the title screen so freakyMenu keeps playing. The player selects from Freeplay, Story Mode, and other options.Freeplay or story mode
- Freeplay (
FreeplayScreen): song list, difficulty selector, and highscore display. Selecting a song transitions to a loading screen. - Story mode: week/story selection. Not yet a separate module in the current
screens/mod.rs, but planned as part of Phase 4.
Loading
A loading screen parses the selected chart via
rustic-core, loads character and stage definitions, and queues audio assets. When loading is complete it transitions to PlayScreen.Playing
play/ contains the in-song screen. It owns a PlayState from rustic-gameplay, a ScriptManager from rustic-scripting, and drives GpuState for rendering each frame. On song end (or game over and retry) it transitions to the results screen or back to freeplay.Why each screen is its own module
V1 had a monolithicmain.rs where screens were functions or large match arms inside a single file. When freeplay was added, changes to the menu flow broke the play screen. Rust’s module system enforces the boundary: the freeplay module cannot directly mutate state in the play module — all communication goes through the transition protocol (next_screen, typed event structs, or shared data passed at construction time).
Adding a new screen
Create the module file
Add
crates/rustic-app/src/screens/my_screen.rs and implement the Screen trait.Transition to it
In the screen that should hand off to yours, implement
next_screen() to return Some(Box::new(MyScreen::new())) when the transition condition is met.