The Plugin Architecture
Harmonicon is built almost entirely out of Bevy Plugins — the standard
Bevy pattern for packaging a chunk of app configuration (resources,
messages/events, systems, sub-plugins) behind a single type that
App::add_plugins consumes. This chapter describes the conventions
Harmonicon layers on top of that pattern: how plugins are composed, how
resources/components/messages are used to communicate between systems,
and how system ordering is expressed and enforced.
Composition: one plugin per feature, assembled in main.rs
Every top-level feature module exposes exactly one public Plugin type
(GameplayPlugin, MenuPlugin, SongPlugin, ThemePlugin,
LessonsPlugin, LocalizationPlugin, SettingsPlugin, ProfilePlugin,
SpectrogramPlugin, AssetsManagementPlugin, plus one per dialogs
widget: ComboboxPlugin, TooltipPlugin, FileDialogsPlugin, and so
on). src/main.rs is the single place all of them get assembled:
Two ordering details here are load-bearing, not incidental:
- The external asset source is registered before
DefaultPlugins. Bevy’sAssetPlugin(part ofDefaultPlugins) builds every registeredAssetSourcewhen it is added, not on first use — registering"external"afterward would meanexternal://...paths silently never resolve, with no assets at all under~/Harmoniconever loading and no error pointing at why. - Microphone capture starts after settings load.
audio_input::start_capture.after(settings::apply_loaded_settings)— a savedAudioSettings::input_devicepreference has to already be in theAudioSettingsresource before capture picks a device, or the game would always start on the system default regardless of what the player configured last session.
A handful of things that aren’t plugins live directly on App in
main.rs instead: app.add_message::<PitchEvent>() and a few
init_resource calls for the microphone pipeline’s own state
(AudioFrame, PitchRange), plus four bare systems (spawn_camera,
process_audio, log_pitches, change_scaling). These are
deliberately not wrapped in their own plugin: they’re two or three
lines each, used only here, and a MicPipelinePlugin wrapping four
lines of registration would be ceremony without payoff. The line is
drawn pragmatically, not by a hard rule — see
Module Boundaries and Dependency Rules for
where this project does enforce structure mechanically (file-size
budgets, a build-time lint for unregistered Message types) versus
where it leans on convention and review.
Sub-plugins for large features
A feature large enough to have its own internal sub-features composes
its own Plugins the same way main.rs composes top-level ones.
GameplayPlugin (in gameplay/plugin.rs) is the largest example: it
add_pluginss nine smaller plugins (CountdownPlugin,
TwelveBarBluesPlugin, MetronomePlugin, ModifierLegendPlugin,
PhrasePlugin, NoteTail2dPlugin, NoteTail3dPlugin,
SongProgressPlugin, WaitFreezePlugin — each one HUD overlay or
gameplay sub-concern) before going on to register roughly twenty
init_resource/add_message calls and around two dozen add_systems
calls of its own for the parts that don’t warrant their own plugin type
(scoring, the clock, pause handling, Jam-Session-specific systems,
Bending-Trainer-specific systems). This mirrors the top-level pattern:
plugin-per-self-contained-overlay where a type gives real encapsulation
value, bare registration calls where it wouldn’t.
Resources vs. components vs. messages
Harmonicon follows Bevy’s own idiomatic split, applied consistently enough across the codebase that it’s worth naming explicitly:
- A
Resourceholds state that exists at most once, globally or per active mode —GameplayClock,Score,AudioSettings,SelectedSong,EditorState(the Song Editor’s entire in-memory document). Most of Harmonicon’s actual “model” data lives in resources, not components — see the callout on this in The Scoring System, which explains why scored notes live in aSongNotesresource (aVec+ cursor) rather than as one ECS component per note. - A
Componenttags an entity — usually either a visual (a spawned note sprite, a UI button) or a marker used to find a particular kind of entity via aQueryfilter (MusicPlayertags the currently-playing background-music entity so pause/volume systems can find it without threading a resource-heldEntityhandle through every call site that needs it). - A
Message(Bevy 0.19’s renamedEvent) is used for discrete, one-shot occurrences a system wants to react to on the frame they happen, rather than poll for continuously —PitchEvent(one per analyzed audio chunk),NoteScored(fired the instantscore_notesjudges a note, consumed by the HUD to animate a hit-feedback burst rather than the HUD pollingScoreevery frame and trying to detect “did this go up just now”),SongsRescanned/ThemesRescanned/LessonsRescanned(fired only when a live filesystem-watcher rescan actually found something new — see Persistence — distinct from the resource simply existing, which a page’s ownresource_changedchange-detection would otherwise also catch on every re-entry into that page).
Every #[derive(Message)] type in the codebase must be registered with
.add_message::<T>() somewhere, or Bevy panics at runtime the first
time some system’s MessageReader/MessageWriter for it actually
runs — a failure mode that’s easy to introduce (the type still compiles
fine unregistered) and easy to not notice until that code path
happens to fire, sometimes well after the type was added. build.rs
statically scans for this at every build and fails the build if it finds
an unregistered one — see Testing Strategy for
the other build-time checks living alongside it.
Expressing system ordering: SystemSets and .after()
Bevy runs a frame’s systems according to a schedule the developer only partially constrains — anything not explicitly ordered may run in any relative order (parallelized where the borrow checker allows it), which is fine for most systems but actively wrong for a few. Harmonicon uses two mechanisms to constrain the parts that need it:
SystemSets name a group of systems so other systems can order themselves relative to the whole group at once, without listing every member. The most important one in the codebase isGameplayLogic(gameplay/plugin.rs): the chain of systems that ticks the gameplay clock, judges scoring, and advances the current bar. Every system that reads the clock — note movement, HUD displays, overlay tints — is ordered.after(GameplayLogic), or it risks reading a stale clock value on some frames and visibly stuttering. See The Gameplay Clock for why this matters as much as it does..after(some_system)orders one system directly after a specific other one, used where the relationship is narrower than “after this whole named phase” — for instance,jam::midi_tracks:: apply_midi_track_mute(see Jam Session) is ordered.after(gameplay::lifecycle::apply_music_volume)so a mid-song global-volume change can never accidentally un-mute a track the player muted a moment earlier: both systems touch the same sinks’ volume, and whichever ran second wins, so the ordering is what guarantees mute always has the final say.
.chain() is used where an entire tuple of systems needs to run in the
literal order written, most commonly for a short setup sequence where a
later system genuinely depends on an earlier one’s resource writes
having already landed (e.g. gameplay_2d::setup/gameplay_3d::setup
reading AdaptiveDifficulty while setup_adaptive_difficulty writes it,
in the same OnEnter(AppState::Playing) tuple).
run_if: conditional execution instead of conditional logic
Rather than a system checking “is this the right mode/state?” as its
first lines and returning early, Harmonicon prefers run_if predicates
attached at registration time — in_state(AppState::Playing),
resource_changed::<AudioSettings>, or a custom closure like
|m: Res<GameplayMode>| *m == GameplayMode::JamSession. This keeps a
system’s own body free of state-checking noise, makes the conditions
under which something runs visible at a glance in the plugin’s
registration block (several such conditions are frequently .and_then-
chained together, e.g. “only in Playing, only when not paused, only in
Jam Session mode” for the Jam-Session-specific systems), and — not
purely cosmetic — means Bevy’s own scheduler can skip a system’s query
initialization entirely on a frame its condition is false, rather than
the system paying that cost and then no-op’ing internally.