Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This book is a contributor-facing description of Harmonicon’s system architecture — how the codebase is put together, how its major subsystems talk to each other, and why they were built the way they were. It is not a player’s guide (that’s docs/book/, the mdBook under the repository’s docs/ directory) and it is not a line-by-line API reference (that’s what doc comments and cargo doc are for). It sits between those two: the level of detail you’d want before making a non-trivial change to the engine, or before reviewing someone else’s non-trivial change to it.

Who this is for

Anyone who wants to work on Harmonicon’s Rust codebase, at any level of familiarity with Bevy or with this project specifically:

  • A new contributor orienting themselves before their first change, who needs to know which of the ~20 top-level modules owns the thing they want to touch, and what invariants they need to not break.
  • A returning contributor who worked on one subsystem months ago and needs a refresher on how it fits into the rest of the game before touching it again.
  • A reviewer checking whether a pull request respects the architecture’s existing boundaries (dependency direction, where pure logic vs. ECS systems belong, which resource owns which piece of state) rather than working around them.

How this book is organized

Each chapter covers one architectural concern, roughly ordered from the most foundational (how the app is built out of Bevy plugins, how top-level state is organized) to the most specific (how one particular feature — the Song Editor, Jam Session, Lessons — is put together internally). Diagrams are PlantUML, rendered inline by mdbook-plantuml at build time — see this book’s own README.md (in contributing/) for how to build it locally, since that needs a PlantUML installation this book’s own dependency tree doesn’t pull in for you.

Every chapter tries to explain design rationale, not just structure: where there was a real alternative and a reason one was chosen over the other, that reason is written down. Where the codebase’s own extensive doc comments (see CLAUDE.md at the repository root — Harmonicon’s own “guidance for Claude Code” file, itself a dense architectural reference) already carry that reasoning, this book draws on it directly, but organized by concept rather than by the chronological order features were added in.

What this book is not

  • Not a tutorial for Bevy itself. It assumes working familiarity with Bevy’s ECS (entities, components, resources, systems, states, plugins) and won’t re-explain those primitives from scratch — see Bevy’s own documentation for that.
  • Not exhaustive API documentation. Function signatures and struct fields change; this book describes shapes and relationships that are meant to stay stable across many such changes. When you need the exact current signature of something, read the code — every module this book references is named so you can find it.
  • Not a substitute for CLAUDE.md. That file is deliberately exhaustive, chronologically organized, and optimized for an AI coding agent to load in full on every session; it is the single most information-dense description of “how does X actually work” in this repository, and it is kept rigorously up to date as a hard project rule. This book is a different cut through much of the same knowledge: organized by concept for a human reading it start-to-finish or jumping to one chapter, with diagrams, and with more room to explain why at a level CLAUDE.md’s bullet-point style doesn’t always leave space for.

Keeping this book current

Architecture documentation rots the moment it stops being read as part of the normal course of making changes. If you land a change that moves a type between modules, introduces a new top-level subsystem, or reverses a dependency direction this book describes, update the relevant chapter in the same change — the same discipline CLAUDE.md and the player’s guide already hold themselves to (see that file’s own instructions: keep planning docs current, prune what’s no longer true rather than letting it accumulate as stale history).

System Overview

Harmonicon is a rhythm game for diatonic and chromatic harmonica, written in Rust on top of the Bevy game engine (version 0.19). The player plays a real harmonica into a microphone; the game listens, detects the pitch being played in real time, and scores it against a scrolling chart — the same core loop as any note-highway rhythm game (Guitar Hero, Clone Hero, osu!), except the “controller” is an acoustic instrument and a live audio pipeline instead of a button press.

The single-crate-with-many-modules shape

Harmonicon is one Cargo package (harmonicon, edition = "2024") structured as a library plus several binaries, not a Cargo workspace of many crates. src/lib.rs is the library root; src/main.rs (the game itself) and everything in src/bin/ (hole-editor, note-editor, note-bench — small developer tools, described in Testing Strategy and the Song Editor chapter) are separate binary crates that depend on that library and share every subsystem through it. This is a deliberate, low-ceremony choice: a full Cargo workspace with separate crates per subsystem would enforce dependency direction at the compiler level (a real advantage — see Module Boundaries and Dependency Rules for how those rules are enforced without that today), but at this project’s size the extra Cargo.toml boilerplate, the friction of moving code between crates while a module’s boundaries are still being found, and the loss of being able to freely pub(crate) things across what would become crate boundaries outweigh that benefit. The module tree inside the one library crate mirrors what separate crates would look like closely enough that splitting it later, if the project ever grows to need that, is a mechanical refactor rather than a redesign.

The top-level modules

src/lib.rs re-exports every subsystem as a pub mod. Roughly grouped by what they’re for (this grouping is informal — Rust doesn’t nest these into sub-namespaces beyond the module tree itself, and the “Module Boundaries” chapter covers the actual, enforced dependency rules between them):

Low-level, widely shared vocabulary — depended on by almost everything else, and deliberately kept ignorant of the features built on top of them:

  • song — the chart file format (HarpChart), harmonica layouts and tunings, MIDI-file parsing, and the custom AssetLoader that turns a chart folder on disk into a loaded SongManifest.
  • audio_system — microphone capture (cpal), the five pitch-detection algorithms, the additive harmonica-voice synthesizer used for playback previews and generated backing, and WAV encode/decode helpers.
  • theme, localization — see Localization and Theming.
  • settings, profile — see Persistence.
  • dialogs — shared, generic UI widgets (buttons, comboboxes, file dialogs, tooltips, scroll areas) used by every screen in the game; intentionally has no idea what a “song” or a “harmonica” is.
  • assets_management — non-chart asset discovery (which songs, themes, harmonica 3D models, and note-head themes exist) and the live filesystem watcher for the ~/Harmonicon external content folder.
  • scoring — the pure, timing-window/combo-multiplier math shared by real gameplay and the Song Editor’s own practice mode. Deliberately just functions operating on plain data, no ECS types at all — see The Scoring System.

Features built on that vocabulary:

  • gameplay — the scored Play 2D/3D modes, the gameplay clock, the Bending Trainer, and every in-song HUD overlay. Also where AppState::Playing’s system schedule is assembled for every GameplayMode (2D, 3D, and Jam Session) — see the composition-root discussion in Module Boundaries.
  • jam — free-play Jam Session, the generated 12-bar backing track, MIDI multi-track backing, and the improv/call-and-response practice modes.
  • lessons — the guided curriculum: lesson manifests, catalog discovery, prerequisite gating, and per-player progress.
  • song_editor — the in-game chart authoring tool.
  • spectrogram — the live audio visualizer (bar spectrum and oscilloscope styles), reusing the same AudioFrame the pitch pipeline already publishes rather than re-analyzing audio itself.
  • menu — every menu screen, app-level state routing, and the guided tutorial tour.
  • app — pure, feature-agnostic vocabulary shared across features: AppState, GameplayMode, the currently-selected song, and a handful of “which menu page to land on when this state exits” routing flags. See Application States and Modes.
  • note_bench — pure comparison logic for the pitch-detection benchmark tool (note-bench); see Testing Strategy.

The engine and its major dependencies

Beyond Bevy itself, a handful of dependencies carry specific, non-interchangeable responsibilities worth knowing about up front — each gets its own discussion in the chapter its responsibility belongs to:

CrateRoleDiscussed in
bevy 0.19ECS, rendering, UI, audio playback, asset systemthroughout
cpalCross-platform microphone captureAudio Pipeline
rustfftFFT for pitch detection and the spectrogramAudio Pipeline
midlyMIDI file parsingChart Format, Jam Session
serde_json / jsonschemaChart/theme/lesson JSON parsing and schema validationChart Format
bevy_fluent / fluent_contentFluent-based localizationLocalization and Theming
figmentLayered settings-file loadingPersistence
notify-debouncer-fullFilesystem watching for ~/HarmoniconPersistence
rodio (decode-only)OGG/WAV waveform pre-analysisChart Format

Where to go next

If you’re orienting yourself for the first time, read The Plugin Architecture and Application States and Modes next — together they describe the skeleton every other chapter’s subsystem hangs off of.

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’s AssetPlugin (part of DefaultPlugins) builds every registered AssetSource when it is added, not on first use — registering "external" afterward would mean external://... paths silently never resolve, with no assets at all under ~/Harmonicon ever loading and no error pointing at why.
  • Microphone capture starts after settings load. audio_input::start_capture.after(settings::apply_loaded_settings) — a saved AudioSettings::input_device preference has to already be in the AudioSettings resource 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 Resource holds 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 a SongNotes resource (a Vec + cursor) rather than as one ECS component per note.
  • A Component tags 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 a Query filter (MusicPlayer tags the currently-playing background-music entity so pause/volume systems can find it without threading a resource-held Entity handle through every call site that needs it).
  • A Message (Bevy 0.19’s renamed Event) 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 instant score_notes judges a note, consumed by the HUD to animate a hit-feedback burst rather than the HUD polling Score every 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 own resource_changed change-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 is GameplayLogic (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.

Application States and Modes

Harmonicon uses Bevy’s States/SubStates machinery to drive which screen is showing and which set of gameplay systems is active. This chapter describes the two state machines involved — AppState (the top-level screen) and GameplayMode (which of three different gameplay experiences AppState::Playing currently means) — plus MenuPage, the sub-state that only exists while AppState::Menu is active, and the routing-flag pattern used to hand information across a state transition that the state machinery itself doesn’t carry.

AppState: the top-level screen

AppState (src/app.rs) is a plain Bevy States enum:

Each transition triggers Bevy’s OnExit/OnEnter systems for the states involved — this is the backbone every feature’s own setup/ teardown hangs off: gameplay::plugin sets up the whole scored-gameplay schedule OnEnter(AppState::Playing) and tears every gameplay entity down OnExit(AppState::Playing) via a shared GameplayRoot marker component (every entity gameplay spawns is tagged with it, so cleanup is one Query<Entity, With<GameplayRoot>> despawn rather than tracking individual entities); song_editor does the same around AppState::SongEditor2.

Startup exists to give localization a real starting point. AppState defaults to Startup, and main.rs only transitions to Menu once localization::localization_ready is true — otherwise the very first frame of the menu would render with raw Fluent keys instead of translated text, because the locale bundles haven’t finished loading yet (see Localization and Theming for why that load is itself asynchronous).

SongLoading exists because asset loading is asynchronous. Picking a song sets SelectedSong to a Handle<SongManifest> and moves to SongLoading; check_loading polls AssetServer::is_loaded_with_dependencies every frame and only advances to Playing once the whole manifest — chart, background image, music, sibling note-theme assets — has actually finished loading. See Chart Format and Asset Loading for what “the whole manifest” includes and why a missing sibling asset needs a fallback rather than becoming a load failure (a hard dependency that never resolves would otherwise hang the loading screen forever, with no error to explain why).

GameplayMode: what Playing actually means

A single AppState::Playing covers three quite different experiences, selected by the GameplayMode resource before entering it:

  • Play2D — falling notes, one lane per hole.
  • Play3D — the same scoring, rendered around a rotating 3D harmonica.
  • JamSession — free play over a 12-bar backing, nothing scored.

All three share the same AppState::Playing OnEnter/OnExit and the same GameplayLogic system set (clock tick, scoring, loop handling — see The Gameplay Clock), which is precisely why they’re one AppState value with a mode selector, rather than three separate AppState variants: AppState::Play2D, ::Play3D, ::JamSession would each need their own copy of every shared OnEnter/OnExit/run_if(in_state(...)) registration (pause handling, music volume application, HUD overlays that all three modes share), or force those registrations to accept a slice of three near-identical match arms. A Res<GameplayMode> read inside a run_if closure — .run_if(|m: Res<GameplayMode>| *m == GameplayMode::Play2D) — is the one line of difference each mode-specific system actually needs.

Jam Session doesn’t populate SongNotes (nothing is scored there), so every scoring-adjacent system either no-ops gracefully against an empty SongNotes or is itself gated to Play2D/Play3D only — see The Scoring System for the specific places this matters.

MenuPage (src/menu/routing.rs) is a Bevy SubStates, declared with #[source(AppState = AppState::Menu)] — it only exists, and only resets to its default (Main), while AppState is Menu. Every menu screen (Play, ArtistList, SongList, ModeSelect, Options, Theme, Lessons, LessonReader, JamSessionMenu, JamGenerate, HelpAbout, About) is one MenuPage value, with its own OnEnter/OnExit pair spawning and despawning that page’s UI.

The routing-flag pattern

A recurring, slightly awkward problem: when some other AppState transitions back into Menu, which MenuPage should it land on? “Wherever it makes sense for where the player just was” — Quit Song should return to the song list, not the main menu; finishing the Calibration screen should return to Options, where the player was adjusting input lag; leaving the Song Editor should return to the Play page. The obvious-looking approach — the exiting screen just calls next_page.set(MenuPage::SongList) directly — doesn’t work: setting NextState<MenuPage> in the same tick as NextState<AppState> loses to SubStates’ own machinery resetting MenuPage to its default the moment AppState actually changes to Menu.

The fix is a small resource per destination — ReturnToSongList, ReturnToOptions, ReturnToPlay, ReturnToHelpAbout (src/app.rs) — each a bare bool. The exiting screen sets its flag to true on every exit path, and route_menu_entry (which runs OnEnter(AppState::Menu), strictly after SubStates’ own reset has already happened) reads the flags and issues the real next_page.set(...) call, one tick later than the naive approach would have. LessonContext and GeneratedJamSession (an in-flight lesson run, and a procedurally generated Jam Session with no real song behind it) follow the same “flag/resource read on arrival, in priority order” pattern for their own end-of-run routing, and the guided tutorial tour (menu::tutorial::TutorialTour) takes priority over all of them while it’s actively driving the screen — see that module for the full priority order route_menu_entry resolves between a running tour, a lesson context, a generated jam, and the four ReturnTo* flags.

The Audio Input Pipeline

This is the pipeline that turns “sound coming out of a real harmonica into a real microphone” into “a PitchEvent message an ECS system can score against a chart.” It is the one place in the codebase where Harmonicon has to cross from a real-time, non-ECS execution context (a cpal audio callback on its own OS thread) back into Bevy’s frame loop, and the design is shaped almost entirely by the constraints that crossing imposes.

Why this can’t just be “read the mic in a system”

Bevy systems run once per frame, on frame-rate cadence (commonly 60Hz, i.e. roughly every 16.7ms). A microphone, through cpal, delivers audio on its own callback, driven by the OS audio backend’s real-time scheduling — a fundamentally different, higher-priority, and stricter timing domain. Two things follow from that:

  • The capture callback must never block. OS audio backends run this callback on a real-time-priority thread; anything that can block (allocating memory, taking a lock also held by a lower-priority thread, doing I/O) risks an audible dropout (“xrun”) if it stalls even briefly. This constraint shapes essentially every implementation detail below.
  • Audio and frame rate aren’t the same clock. The callback fires however often the audio backend wants a new buffer, completely decoupled from whatever the game’s frame rate happens to be. A system can’t just “read the microphone” the way it reads a KeyCode — there has to be a hand-off between the two timing domains.

The pipeline, end to end

Capture: audio_system::audio_input

start_capture opens a cpal::Stream against the configured (or system-default) input device and registers push_chunks as its data callback. push_chunks accumulates incoming samples into fixed CHUNK_SIZE (4096-sample) buffers with 50% overlap — each chunk shares its second half with the next chunk’s first half (HOP_SIZE = 2048), which is standard practice for windowed-FFT pitch detection: it halves the effective latency between “the reed starts sounding” and “the chunk containing that onset gets analyzed,” at the cost of running the FFT roughly twice as often for the same audio.

No allocation in the real-time callback. A Vec<f32> chunk buffer that would need malloc on every hand-off is exactly the kind of blocking-risk operation the callback can’t afford. Instead, AudioCapture::free_sender is a channel the consumer side (process_audio, safely inside the Bevy frame loop) uses to hand a buffer back once it’s done with it — push_chunks drains that channel first and only allocates a fresh buffer as a fallback if the pool is ever empty (startup, or the consumer briefly falling behind). In steady state, the consumer drains far faster than chunks arrive (one FFT per ~46ms chunk vs. an Update frame roughly every 16.7ms), so the pool essentially never runs dry.

Hand-off: crossbeam-channel

The callback thread and the Bevy Update schedule communicate purely through a bounded crossbeam_channel (AudioCapture::receiver / free_sender, both stored on the AudioCapture resource) — no shared mutable state, no locks either side has to reason about, just send/receive across a well-tested MPSC channel. process_audio drains every pending chunk with while let Ok(...) = receiver.try_recv() each frame rather than reading just one — because chunks arrive on their own real-time cadence, more than one can legitimately land within a single (slower) Update frame, and dropping the extras would just be discarding already-captured audio for no reason.

Analysis: pitch_detect::analyze

analyze (audio_system/pitch_detect.rs) takes one chunk, the sample rate, the selected PitchAlgorithm, and the current PitchRange, and returns an Analysis: the detected Vec<PitchInfo> plus the FFT magnitude spectrum and its bin width (freq_res) — the same spectrum data the spectrogram visualizer reuses instead of re-running its own FFT, since it’s already sitting in the published AudioFrame resource.

Five selectable algorithms, chosen via the PitchAlgorithm enum and switchable live from the Options page:

AlgorithmCharacter
FFT (default)Peak-picking with harmonic suppression on the FFT spectrum. Can report multiple simultaneous pitches — the only algorithm here that can, which matters for chord-tone detection.
YINCumulative mean-difference function. Monophonic (one pitch), classically robust for a single clean voice/instrument.
pYINProbabilistic YIN — aggregates YIN’s own estimate over a Beta-weighted range of candidate lags for a more stable estimate at the cost of more computation.
MPMMcLeod Pitch Method — normalized square-difference autocorrelation; often the most reliable choice for a single harmonica note (see docs/lessons_plan.md/CLAUDE.md’s recording-workflow notes, which specifically recommend it for Song Editor Record mode).
NMFNon-negative matrix factorization against a dictionary of harmonica note spectra, rebuilt whenever the detection range changes. The one algorithm that’s chart-aware by construction, since its dictionary is built from exactly the notes the current harp/chart can produce.

PitchRange is chart-driven, not a fixed global constant. Detecting across the full audible spectrum wastes computation and increases false positives from other pitches a harmonica simply cannot produce (a different instrument bleeding through the mic, room noise at an implausible pitch). PitchRange (default 200–2500 Hz) is narrowed to Harmonica::frequency_range() — the specific harp’s actual playable range — the moment a song starts, and to the selected key’s range in the Bending Trainer; both reset it to the default on state exit. This is also why NMF’s dictionary staleness check has to include the range as an input, not just the algorithm choice: a dictionary built for one harp’s notes would silently misclassify pitches from a different one.

Publishing: PitchEvent and AudioFrame

Two different things get published from the same analyze call, because they have two different kinds of consumer:

  • PitchEvent (a Message) — the detected pitches, for anything that reacts to “what changed this frame”: scoring (collect_pitches in gameplay), the Song Editor’s live recording, Jam Session’s hole- map tinting.
  • AudioFrame (a Resource) — the raw FFT magnitude spectrum and the just-analyzed sample buffer, for anything that wants continuous access to “what does the signal currently look like” rather than a discrete event — the spectrogram visualizer being the only consumer today.

Downstream: synthesis and file decode

audio_system also owns two pieces that have nothing to do with capturing audio but share its “audio infrastructure, not a feature” status:

  • synth.rs — an additive harmonica-voice synthesizer (render_pcm, operating on a Vec<PhraseNote> tick/frequency list) used everywhere Harmonicon needs to produce a harmonica-like sound rather than detect one: the Song Editor’s Play/Practice/Record preview and note-audition blip, gameplay::call_response’s one-shot “call” demo audio, and — the newest consumer — per-track MIDI backing stems for Jam Session (see Jam Session). Vibrato/FM modulation here integrates frequency over time (a phase accumulator), never modulated_freq × t directly — the latter drifts pitch upward over the duration of a held note, a subtle bug that’s easy to reintroduce if this synth is ever touched without knowing why the phase-accumulator form was chosen.
  • waveform.rs/wav.rs — OGG/WAV peak-amplitude pre-analysis (via rodio, decode-only) and WAV encode, used at asset-load time to give the song-progress bar a waveform to draw immediately rather than decoding audio on the main thread mid-setup, and to turn synthesized PCM back into a playable AudioSource (the generated Jam Session bass line, MIDI-track backing stems, MIDI-import backing tracks).

Neither of these touches cpal or the real-time callback at all — they run as ordinary (if sometimes CPU-heavy) synchronous code, either inside a custom AssetLoader (see Chart Format and Asset Loading) or inside an ordinary system, and carry none of the real-time constraints the capture side does.

The Gameplay Clock

Every timing-sensitive system in scored gameplay — where a falling note currently is, whether it’s inside its hit window, what the HUD displays, where the phrase/song-progress overlays are drawn — reads its notion of “now” from exactly one place: GameplayClock (gameplay/clock.rs). This chapter explains what that clock actually is, why it isn’t simply “the real-time elapsed since the song started,” and the API design that makes its one hard invariant difficult to violate by accident.

Why not just use real elapsed time?

The naive approach — track Instant::now() - song_start and treat that as the authoritative song position — breaks the moment the audio itself doesn’t advance in perfect lock-step with wall-clock time. In practice it never does: audio decoders have startup latency, backends occasionally hitch, and a bevy_audio/rodio sink’s own reported playback position is the one source of truth for “where is the actual sound the player is hearing right now.” A note judged against wall-clock time while the audio is a few milliseconds ahead or behind reads as mistimed even when the player’s timing was perfect — the judgment is comparing the player against the wrong reference.

So GameplayClock doesn’t track wall-clock time directly; once music starts, it tracks the audio sink’s own position, with wall-clock delta as a fallback for the periods where there’s no sink to anchor to at all (the pre-song countdown, Jam Session, before the sink has produced its first position report).

The anchoring algorithm

Two constants make this concrete: MAX_RATE_ADJUST (0.5%) caps how much the clock’s rate — not a one-off jump — can deviate from real time while gently correcting toward the sink, expressed as a rate rather than a fixed per-frame step specifically so the correction doesn’t bias every judged hit offset by a constant amount for as long as it’s active, and so it doesn’t over- or under-correct depending on the actual frame rate. SNAP_THRESHOLD_SECS (0.5s) is the line between “ordinary jitter, worth correcting gently” and “a real discontinuity” (a decoder stall, a backend seek) that should be corrected immediately rather than converged toward over several seconds of visibly-desynced notes.

Jam Session deliberately never anchors. There’s no long, fixed-length track to drift against in free play — Jam Session’s music is either a generated backing loop or a picked song played on repeat, and nothing about that experience benefits from the sink-anchoring machinery. should_anchor_to_sink excludes GameplayMode::JamSession explicitly.

The encapsulation: why the inner value is private

GameplayClock’s inner f64 is a private tuple field. The type exposes exactly three ways to change it, and each one documents (or, for the anchored case, actively enforces) the invariant that matters:

The invariant all three exist to protect: anything that jumps the clock must also seek the music sink, or suspend anchoring — because if it doesn’t, the very next tick_clock pass sees the sink still sitting at its old position, computes a large “drift,” and drags the clock right back toward where it just jumped from. This is a genuinely easy bug to write by hand (assign the new time, forget the sink is now stale) and a confusing one to debug (the symptom is “my rewind gets silently undone one frame later,” with no error or panic pointing at why) — which is exactly the shape of bug a private field plus a small, invariant-carrying API is good at ruling out at the type level rather than relying on every future caller remembering a rule from a comment.

rewind_to is the one both existing callers that jump the clock already use — handle_loop_boundary (an A–B loop or a chart’s own loop section reaching its end point) is the only one in the codebase today, but the doc comment above is explicit that this is also the contract any future practice-speed-change or manual seek feature must follow, not an incidental detail of the current feature set.

Reading the clock: the ordering invariant

The other half of the contract, enforced by convention rather than the type system: every system that reads the clock must be ordered .after(GameplayLogic) (the SystemSet that ticks it — see The Plugin Architecture). Bevy’s scheduler is free to run unordered systems in any relative sequence, including parallelized; a clock-reading system that isn’t explicitly ordered after the tick can read last frame’s value on some frames and this frame’s on others, which manifests as visible note-movement stutter rather than a crash — the kind of bug that’s easy to introduce and easy to miss in testing if it only shows up as an occasional single-frame jitter.

Practice speed and pausing

Two more free-running cases fall out of the same should_anchor_to_sink predicate rather than needing special-case code: practice speed below 100% (real time-stretched audio isn’t implemented, so the sink is simply paused and the clock free-runs on Time::delta scaled by the practice-speed factor instead — returning to 100% re-seeks the sink to the clock’s current position via rewind_to before resuming it, since the sink sat still the whole time the clock kept moving), and the wait-for-note freeze (wait_freeze_overlay — the chart holds at the next unjudged note until the player plays it; tick_clock pauses the sink and skips advancing the clock at all while a note is “due” under that mode, and Jam Session never populates SongNotes, so the freeze condition is always trivially false there regardless).

The Scoring System

Scoring is split cleanly into two layers: pure functions with no ECS dependency at all (src/scoring.rs, top-level, shared by real gameplay and the Song Editor’s practice mode) that decide “given this timing offset and this configuration, what quality is this hit,” and a driving ECS system (gameplay::judge::score_notes, plus its supporting resources in gameplay::state) that feeds real per-frame pitch/clock data into those functions and applies the result to game state. This chapter covers both layers and the data-model decision that sits underneath them: why scored notes live in a plain resource instead of as ECS components.

Why note state is a resource, not components

It would be reasonably idiomatic Bevy to give every chart note its own entity with a Note component carrying its score state. Harmonicon doesn’t do this. Instead, SongNotes (gameplay/notes.rs) is a Resource holding a plain Vec<ScheduledNote> (the entire chart, loaded once) plus a cursor (the index of the first not-yet-resolved note). ScheduledNote is plain data — no Component derive, nothing ECS-specific about it at all.

This decoupling is what makes several other things in the codebase simple that would otherwise be awkward:

  • Only a rolling window of notes needs a visual at all. notes_needing_spawn (a pure function, shared by the 2D and 3D renderers) answers “which notes should have a visual right now but don’t” by binary-searching SongNotes::notes (kept sorted by time) for the LOOKAHEAD-second window around the playhead — a whole song is typically hundreds of notes, but only a handful are ever visible (and thus need an entity) at once. If score state lived on the entity, a note leaving the lookahead window would either need to destroy and later reconstruct its own progress, or the despawn logic would need special-casing to preserve it — with the split, an entity can freely despawn and later respawn fresh, because its score state was never at risk of being lost with it.
  • Looping resets state with a slice mutation, not an ECS query. clock::handle_loop_boundary resets every note inside the loop range back to “unresolved” with a binary-search-bounded slice mutation over plain data, rather than a Query over however many of those notes happen to currently have a live entity (most of them won’t, being outside the lookahead window).
  • Recolor-on-hit systems need no Changed<T> filter. ScheduledNote isn’t a component, so there’s nothing to filter a Query on for “did this note’s score state change since last frame.” Instead, update_note_visuals* just re-syncs every currently-spawned visual’s color from SongNotes every frame — cheap, because (per the point above) only the lookahead window’s worth of notes ever have a visual to update in the first place.

Pitch identity: a u8, not a string

Every place scoring needs to compare “what pitch is the player playing” against “what pitch does the chart expect,” both sides are a MIDI note number (u8) — PitchInfo::midi, ScheduledNote::expected_pitch: Option<u8> (None for a hole/technique combination the harp simply can’t produce, which can therefore never be hit), PitchGate’s consumed: HashSet<u8>. This is what lets the hot per-frame comparison be an integer equality check with zero allocation, and — a correctness detail, not just a performance one — it rules out enharmonic mismatches ("A#4" vs "Bb4") entirely, since they’re the same integer. Display strings (note/octave on PitchInfo, the harmonica’s own wind_direction_label) still exist purely for what the player sees; they are never compared against each other for scoring purposes.

score_notes: candidate selection

judge::score_notes runs every frame (as part of GameplayLogic, see The Gameplay Clock) and has to answer “which of however many notes are currently near the playhead did the player’s detected pitches just satisfy?” It does this in two passes over SongNotes, both leaning on the notes being sorted by time:

Sorting candidates by |offset| before judging — rather than judging in whatever order they happen to appear in the chart — is what makes two overlapping same-pitch notes resolve deterministically: the closer one claims the played pitch first, so a played note can never be credited to the wrong one of two nearby candidates just because of iteration order. The early break (not continue) on the first out-of-range note is possible, and correct, specifically because the notes are sorted and the scan starts from cursor — nothing beyond that point can be in range either, so there’s no reason to keep scanning a long chart’s entirely future notes every single frame.

Fresh-attack gating: PitchGate

A note only counts as “being played” on a genuine new attack, not merely “this pitch happens to still be sounding from a moment ago” — otherwise a single long blow could be re-credited to every note at that pitch in a row. PitchGate (gameplay/state.rs) wraps scoring::AttackGate<u8>, a small pure state machine keyed by MIDI note number that tracks which pitches are “fresh” (just started sounding) versus “already consumed” by an earlier note’s hit — the same primitive Jam Session’s own ImprovStats accumulator reuses for its own fresh-attack gating, despite scoring nothing.

Chord and octave-split notes

A chart TrackItem with more than one simultaneous events entry (PlayMode::Chord/Split) still produces one ScheduledNote per event — but every sibling note carries the same chord_pitches: Vec<u8>, the full target set for that item. score_notes ANDs a chord_is_sounding check (every pitch in the set present at once) into each sibling’s own per-pitch freshness check, so a chord only scores when its members are struck together — playing the same holes one at a time doesn’t satisfy it. This needed no chart-schema change at all: multi-event TrackItems already existed (for the visual chord/split badge); nothing previously required their events to actually sound together, and chord_pitches is empty (a no-op AND) for the overwhelmingly common single-event case.

Where the pure/ECS boundary actually sits

src/scoring.rs — outside gameplay/ entirely, at the crate’s top level — contains every function that decides quality from a timing offset and a ScoringConfig: the perfect/good/miss timing-window classification, combo/multiplier math, style-bonus point values, and AttackGate<K> itself. None of it touches a World, a Query, or any Bevy type. This is what lets the Song Editor’s practice mode (song_editor::practice) share the exact same judging math real gameplay uses — scoring a player’s mic input against the chart being edited — without depending on any part of gameplay’s ECS machinery at all, and it’s what makes this layer straightforward to unit test directly against plain inputs and expected outputs rather than needing a World/Schedule harness (see Testing Strategy for the project-wide convention this exemplifies: pure functions and their tests first, the ECS system that drives them second).

Chart Format and Asset Loading

A “song” in Harmonicon is more than a chart file — it’s a chart plus a loose bundle of optional sibling assets (music, art, per-song note themes), all pulled together into one loaded SongManifest by a custom Bevy AssetLoader. This chapter covers the on-disk chart format, the loader that turns a folder into a SongManifest, and the specific design pattern — checking a sibling’s existence before making it a hard dependency — that lets a song ship with almost nothing beyond its chart and still load correctly.

The chart format: .harpchart

A chart is a JSON file (song::chart::HarpChart), validated at load time against assets/song_schema.dtd.json via the jsonschema crate. Its top-level shape:

  • metadata — including format_version, checked (not just descriptive) against song::chart::CURRENT_FORMAT_VERSION; a chart declaring a newer version than the running build understands is rejected with a clear “this chart needs a newer Harmonicon” message rather than failing confusingly downstream against a schema it wasn’t written for. An older chart goes through chart::migrate_chart_json first (see below) rather than being checked directly — a chart old enough to still carry a since-removed field would otherwise fail schema validation before this check is even reached.
  • song — title, artist, tempo, key, time signature.
  • harmonica — diatonic or chromatic, hole layout, tuning/bending profile, and (see the Song Editor and Jam Session chapters) the reference scale used for out-of-scale coloring.
  • timingresolution (ticks per quarter note) and a tempo_map (a sorted list of (tick, bpm) points) — see The Song Editor for why this is a real, chart-declared value rather than a hardcoded constant, and how that choice paid off when the Song Editor’s own tick resolution later changed.
  • track — the timed list of notes: each item has a time (or tick, resolved against the tempo map) and one or more note events (hole + blow/draw + expected pitch), optionally carrying technique modifiers (bend, overblow, overdraw, slide, vibrato, wah-wah).

Schema strictness is deliberate, and has a real consequence for schema evolution. Every level of the schema sets additionalProperties: false, so removing a field from the schema would break previously-authored charts still carrying it at validation time (a plain serde struct would just silently ignore an unknown field; the schema validator won’t). A field removal has three legitimate ways to handle this — keep the old key present-but-ignored in the schema, accept the break explicitly and bump format_version, or add a migration step (below) that fixes the content up before validation ever sees it. The original fx_mapping removal did none of these, which is exactly why old charts carrying it failed to load; migrate_chart_json (below) fixes that specific case retroactively, and is the template for handling any future removal the same way instead of repeating that gap.

Migrating old charts on load

song::chart::migrate_chart_json runs on the raw serde_json::Value before schema validation — migrating after would be too late, since a chart that fails validation never reaches typed deserialization at all. It walks an ordered list of Migration { target_version, apply } steps (currently one: strip_legacy_fx_mapping, folded into 1.1.0), skipping any step whose target_version is already covered by the chart’s own declared metadata.format_version — a missing or unparsable version is treated as older than everything, since almost every chart old enough to need migrating predates the field itself.

Each step’s apply returns whether it actually changed anything, kept deliberately separate from whether it was merely attempted: most charts below a step’s version threshold don’t actually have the specific problem it fixes (a chart declaring 1.0.0 that never used fx_mapping to begin with, say). song::loader only logs when real content changed; metadata.format_version itself is unconditionally stamped to CURRENT_FORMAT_VERSION whenever any step’s threshold applied, whether or not that step found something to fix, so a chart that’s passed through here once never gets re-evaluated against the same migrations again.

This is purely an in-memory fix-up for that one load — nothing writes the migrated JSON back to the .harpchart file on disk. A re-save through the Song Editor naturally picks up the fix, since it serializes whatever’s currently in memory, but an external chart a player never re-saves stays migrated-on-load, indefinitely, every time it’s opened.

SongManifest: the loaded, in-memory result

SongManifest is itself a Bevy Asset, registered with app.init_asset::<SongManifest>().register_asset_loader(SongChartLoader) (song::SongPlugin). Picking a song sets SelectedSong(Handle< SongManifest>) and moves AppState to SongLoading; menu::routing::check_loading polls AssetServer:: is_loaded_with_dependencies every frame and only advances to Playing once the whole manifest — chart plus every asset it depends on — has resolved (see Application States and Modes).

SongChartLoader: the custom AssetLoader

song::loader::SongChartLoader (type Asset = SongManifest) is an async AssetLoader — it runs as a future on the AssetServer’s IO task pool, off the main thread, alongside every other asset load in the game. Its job is considerably more involved than “parse the JSON,” because every sibling asset a song folder can ship is optional except the chart itself:

The load-order subtlety that makes all of this work: every sibling is checked for existence with read_asset_bytes before it’s ever handed to load_context.load(). Calling load() registers that path as a hard dependency of the SongManifest asset — and a dependency pointing at a file that doesn’t exist never resolves, so AssetServer::is_loaded_with_dependencies (the very check check_loading polls) would wait on it forever. Without the existence check first, a song shipping only a chart wouldn’t fail loudly — it would just hang on the loading screen indefinitely, with no error message pointing at why. Example Song 3 in the bundled assets exists specifically to exercise this path: it ships only a chart, on purpose, so this fallback behavior stays covered by tests/asset_layout.rs rather than silently regressing.

2D/3D note assets are noted, not loaded, here. assets_2d/ assets_3d are stored as an AssetPath, not a Handle — loading them here would make them a manifest dependency kept resident for the entire song regardless of which render mode (if either) actually gets entered. gameplay_2d::setup/gameplay_3d::setup load the matching one on demand, and free it again on exit.

Why the asset source matters: bundled vs. external

Every sibling read above goes through load_context, which resolves relative to whichever AssetSource the manifest itself was loaded from — the default (bundled assets/) or external:// (the ~/Harmonicon drop-in folder registered in main.rs, see The Plugin Architecture). The loader captures this explicitly (load_context.path().source().clone_owned()) and threads it through every sibling path it builds, rather than ever using a bare PathBuf/&str — a bare path always resolves against the default source, so without this, a song loaded from external://... would have its own chart parsed correctly but its music/images silently looked up in the bundled tree instead. The one deliberate exception: the fallback note-theme JSON (notes/2d/circular.json) is loaded with a bare, source-less path on purpose — a shared default asset lives in the bundled tree regardless of where the song itself came from.

MIDI as a chart source: two very different roles

midly-based MIDI parsing appears in two places in the codebase with deliberately different responsibilities, both built on the same low-level, pure parsing module (song::midi — track names, tempo maps, note on/off pairing, no pitch-to-harp resolution or chart-building logic at all):

  • Authoring: song_editor::midi_import reads a MIDI file the player picks in the Song Editor, resolves its pitches onto the currently selected harp, and drops the result onto the note grid as ordinary, editable chart notes — a one-time, offline conversion. See The Song Editor.
  • Runtime backing: song::loader’s song/music.mid fallback (this chapter) keeps a MIDI file’s tracks separate rather than converting them to chart notes at all, rendering each to its own playable audio stem via the shared additive synth. See Jam Session for how those stems become independently mutable playback.

Both share song::midi’s parsing primitives, but neither depends on the other — one produces chart notes, the other produces chart audio, and song::midi itself stays agnostic of which a given caller is doing.

The Song Editor

The Song Editor (src/song_editor/, ~15,000 lines across roughly thirty files) is Harmonicon’s largest single feature: a full in-game chart authoring tool built around one central document resource, a piano-roll grid, and enough surrounding tooling (live recording, MIDI import, undo/redo, a real tempo map, lesson authoring) that it functions as a small application in its own right, running inside AppState:: SongEditor2. This chapter describes how that document is modeled, how the ~30 files are split by responsibility, and the design of a few of its more intricate features.

EditorState: one resource, the whole document

Everything the editor is currently working on — every placed note, the tempo map, the meta-form fields (key, tempo, harmonica type, position, scale, lesson metadata), the current selection, drag state, which mode is active — lives on one Resource, EditorState (song_editor/ state.rs). This mirrors the same “plain data resource, not scattered across components” choice The Scoring System describes for SongNotes, for a very similar reason: the editor’s “document” needs to be trivially snapshot-able (for undo — see below), diffable, and serializable to a .harpchart, none of which is convenient if a note’s data is spread across ECS component storage.

GridNote (the editor’s own per-note type — distinct from gameplay’s ScheduledNote, since the editor’s notes carry authoring-time fields gameplay’s scored notes don’t, like a stable id used for selection/undo) is plain data too: { id, hole, tick, len, dir, pitch, expr }.

How the module is split

The screen is a row, not a column

The editor root is a row: a vertical tool sidebar down the left, and everything else in a column beside it. Within that column, the note grid and the metadata form are tabs (ChartTabPanel/DetailsTabPanel, toggled with Display::None so the hidden one reserves no space).

Both choices are about height, and both were driven by running the game on a phone. A landscape screen is wide and short — 2400x1080, roughly 400 logical px tall at Android DPI — while the grid alone needs about 424. The tool palette used to be a horizontal strip below the grid, which put it off the bottom of the screen with nothing able to scroll to it, because it sits in the fixed chrome outside the form’s ScrollArea. Turning it vertical spends width, which that screen has in abundance, instead of height, which it does not.

view_scroll.rs owns everything that moves the view rather than the content: the grid’s horizontal pan (keys, wheel, scrollbar), the two-finger touch pan, and the sidebar’s own drag/wheel scrolling. It was split out of interaction.rs for the file-size budget, but the group was always coherent — none of it touches a note.

This split follows a consistent pattern also visible elsewhere in the codebase (see Module Boundaries and Dependency Rules): most files aren’t “a layer,” they’re “one feature, factored out once it grew large enough to justify its own file” — snap.rs, audition.rs, save_feedback.rs, metronome.rs, and undo.rs were all split out of state.rs/mod.rs specifically to stay under the project’s enforced per-file line budget (see Testing Strategy), not because of some deeper layering principle. The underlying rule that is structural, not incidental: pitch-to-harp resolution (pitch_map.rs) is shared, not duplicated, between MIDI import and live recording, even though the two want opposite fallback behavior when a pitch doesn’t map cleanly onto the harp (import always finds something playable, so an imported track never has gaps; recording discards a detection it can’t map cleanly, so raw pitch-detector noise never disguises itself as a plausible note).

Undo/redo: snapshot diffing, not command objects

The editor’s undo system (undo.rs) is snapshot-based, not command-based. A command-based undo system (each mutation pushes an explicit “undo this specific action” closure or enum onto a stack) is the more common pattern, but it requires instrumenting every single mutation call site — every note placement, drag, resize, delete, paste, Erase/Remove, MIDI import — to know it needs to record undo information, which is a lot of individually-easy-to-forget call sites in an editor this large.

Harmonicon’s undo::track_changes instead runs once every frame EditorState changes at all, diffing a lightweight Snapshot (just notes and tempo_changes — deliberately not the whole EditorState, excluding transient fields like selected/scroll_beat/ dragging that shouldn’t count as “an edit” for undo purposes) against the last-seen snapshot, and pushes the previous one onto the undo stack only when they actually differ:

This means adding a new note-mutating feature needs zero integration work with undo — whatever it does to EditorState.notes/ tempo_changes is caught generically on the next diff pass, which is exactly why undo support didn’t need to be re-derived when MIDI import, paste, and the timeline tools were each added afterward. The one deliberate exception is live recording: a note’s length grows every single frame while a take is running, so diffing continuously would flood the history with one entry per frame of growth — track_changes simply skips while RecordState::active, so an entire take (onset through Stop/Finish, pauses included) becomes one undo step, not hundreds. undo()/redo() themselves also keep the cached “last” snapshot in sync, so the very next track_changes pass after either one correctly reads as a no-op rather than a spurious new edit that would otherwise clear the redo stack.

The grid-snap feature: a case study in scoped extension

The grid’s snap-to-beat-subdivision feature (SnapMode — Straight 16ths, Shuffle, Triplet) is a useful worked example of incremental, carefully-bounded architecture change, because it was built in two passes that make a good illustration of “ship the minimum, verify, then extend deliberately”:

  1. First pass: TICKS_PER_BEAT (shared with audio_system::synth — see The Audio Input Pipeline) went from 4 to 12, the lowest resolution divisible by both 4 (straight 16ths) and 3 (triplets) — a true triplet position simply doesn’t exist as an integer tick on a 4-ticks-per-beat grid, so this had to be a resolution change, not a smarter snapping function on the old grid. snap_tick_in_beat (a pure function taking a fractional position within one beat cell — a click’s normalized offset) was wired into the note-placement click handler only.
  2. Verification surfaced a scope gap: manual testing found dragging and resizing an existing note showed no visible difference between snap modes — because move_target/apply_resize (the pure functions computing a drag’s resulting tick) had no SnapMode input at all, by design at the time. Whether that was the right final scope was a real, open question, not a bug — dragging free-form and only snapping fresh placements is a defensible design on its own.
  3. Second pass, after confirming the broader scope was wanted: snap_absolute_tick (a second pure function, taking an already-absolute tick rather than a within-one-beat fraction — drag deltas can cross beat boundaries, which the placement-only function was never built to handle) is applied as a post-processing step at each drag observer’s call site. move_target/apply_resize themselves stay completely snap-agnostic — snapping is layered on after calling them, not threaded through their own signatures — which is why their existing unit tests needed zero changes when this landed.

The broader lesson this illustrates: a feature’s first correct, tested, shipped version does not have to cover every place a related concept applies — placing a note and dragging one are related but distinct interactions, and it was legitimate to ship the first as its own complete unit, confirm with the person who’d actually use it whether the second needed the same treatment, and only then extend — rather than either guessing at the full scope up front or leaving the gap undiscovered.

Meta-form text fields: real EditableText, not hand-rolled input

The nine free-text meta-form fields (Tempo, Music, Name, Author, and five lesson fields) are built on bevy_text::EditableText via a small shared widget, dialogs::text_input::spawn_text_input — real click-to-focus, cursor rendering, and keyboard editing supplied by bevy_ui_widgetsEditableTextInputPlugin (already registered app-wide), rather than a hand-rolled per-character KeyboardInput capture. The five click-to-cycle fields (Key, Position, and three lesson-enum pickers) are unrelated — plain WidgetButtons that step their value on Activate, never text input at all.

Two consequences of building on real focus/editing primitives instead of a custom EditorState.focus: Option<Field> flag (which this used to be):

  • Keyboard-shortcut gating reads real InputFocus. interaction:: grid_keys/handle_copy_paste/handle_undo_redo/pan_keys each check Res<InputFocus> against Query<(), With<EditableText>> (a small shared helper, a_text_field_has_focus) instead of a bespoke flag, so Delete/Ctrl+C/V/Z/Y/arrow-panning correctly stay disabled while typing. This also means Tab-focusing a field and typing immediately now works — under the old flag-based system it silently didn’t, since only a click (never Tab) ever set the flag.
  • Programmatic writes need their own sync path. A field’s EditableText buffer is its own live source of truth while focused, so EditorState can’t just be blindly redrawn into it every frame the way the old MetaFieldText display was — EditorState changes on essentially every frame during ordinary editing (note placement, selection, …), and a naive resync would erase whatever the player is mid-typing. panel:: sync_meta_field_text handles this: it reconciles every field’s buffer against EditorState::field_text every frame, but skips whichever field entity currently equals InputFocus::get(). This is what lets Load, MIDI import, and Browse-picking a music file all update a field’s displayed text with zero code of their own — they just write EditorState’s plain String fields as before, and the sync system picks it up on the next frame (unless the player happens to be typing in that exact field at that exact moment, in which case it’s skipped until they move on).

Save/load and the schema/version contract

Saving serializes EditorState into the same .harpchart JSON shape Chart Format and Asset Loading describes, through harpchart::serialize_harpchart, and validates the result against the same schema before writing — an editor that could produce a chart its own game engine then refuses to load would be a uniquely bad experience. Save/load outcomes surface in the status bar (not just the log) via save_feedback::SaveFeedback, a small resource with a message and a countdown timer, displayed as the status bar’s highest-priority tier for a few seconds before falling back to whatever it would otherwise show (a count-in countdown, a drag-validity message, recording/practice status).

Where MIDI enters: import, not runtime backing

The Song Editor’s midi_import.rs is the authoring-side use of MIDI files, distinct from — but built on the same underlying song::midi parsing primitives as — the runtime MIDI-backing feature described in Jam Session. Picking a MIDI file lists its tracks in a combobox; picking a track quantizes its notes onto the editor’s tick grid and resolves each pitch onto the currently selected harp key via pitch_map::map_pitch (an exact match, else a bend or slide, else the nearest playable note — reusing the exact same compatibility check the editor’s own UI enforces, so an import can never produce a note the grid wouldn’t otherwise let you place by hand). Saving while a track is selected also writes a synthesized WAV mixdown of every other track as song/music.wav, via the same additive synth The Audio Input Pipeline describes — this predates, and is a different design point from, the newer song/music.mid per-track-stem backing Jam Session can use instead.

Jam Session

Jam Session (src/jam/) is Harmonicon’s free-play mode: a rolling 12-bar backing, a live hole-map guide, and — deliberately — nothing scored. This chapter covers how it shares gameplay’s core infrastructure without being a gameplay submodule itself, the two ways it can produce backing audio (a procedurally generated bass line, or a picked song), and, in the most depth, the MIDI multi-track backing and per-track mute feature — the newest and architecturally most interesting piece of this subsystem, since it’s the one place in the codebase that plays more than one background-music sink at once.

Why jam is a sibling of gameplay, not part of it

GameplayMode::JamSession is one of three values a Res<GameplayMode> can hold while AppState::Playing is active (see Application States and Modes) — so in one sense, Jam Session is a gameplay mode. But its own code lives in a separate top-level module, jam/, not inside gameplay/. This is a deliberate split, and it reads as a genuine two-way dependency at first glance — worth being honest about rather than glossing over, since it’s the kind of thing Module Boundaries and Dependency Rules exists to explain clearly:

gameplay::plugin (the composition root — the one place that assembles the entire AppState::Playing schedule for all three GameplayModes) imports from jam to register Jam-Session-specific systems (jam::session::update_hole_map, jam::midi_tracks:: apply_midi_track_mute, and so on) into that shared schedule, alongside gameplay’s own 2D/3D-specific systems. jam’s actual feature code, in turn, depends on gameplay’s core primitives — GameplayClock, MusicPlayer, MidiTrackPlayer — as shared low-level vocabulary, the same way song_editor or any other feature would. What makes this not a real circular dependency in the problematic sense: a composition root is expected to depend on everything it wires together (that’s its whole job), while jam’s own feature logic never reaches back into gameplay’s feature logic (2D/3D rendering, scoring) — only into the primitives gameplay::state exists specifically to share. jam is a sibling feature that happens to plug into gameplay’s shared schedule, not a sub-feature of it.

Two ways to get a backing track

“Pick a Song” routes through the ordinary song-loading pipeline described in Chart Format and Asset Loading — any bundled or ~/Harmonicon song can be jammed over.

“Generate a Jam” is the no-existing-song alternative: jam::backing::build_generated_manifest synthesizes a 12-bar bass line at runtime (generate_bass_pcm — a simple 3-harmonic sine “blues box” pattern, deliberately a different, plainer instrument voice from the harmonica-voice synth song_editor::playback/MIDI backing use, since a backing bass shouldn’t compete with or resemble the thing the player is playing), encodes it to WAV, and registers it as a real AudioSource asset directly via Assets::add — never going through the AssetServer’s load path or AppState::SongLoading at all, because there’s no file on disk to load in the first place. A GeneratedJamSession marker resource is what lets the surrounding menu routing (see Application States and Modes) recognize this case and skip the loading screen, and lets “Quit Song” route back to the generation page instead of a song list a generated jam never went through.

MIDI multi-track backing: independent, synchronized sinks

This is the newest piece of jam, and the one with the most interesting architecture, because it’s the first (and so far only) place in Harmonicon that plays more than one background-music sink simultaneously.

The constraint that shaped the design

Every other audio-producing path in the codebase — the generated bass line above, the Song Editor’s MIDI-import backing mixdown, every preview/practice/record playback, gameplay::call_response’s one-shot call demo — follows the same shape: build a note list, render it once to a flat PCM buffer via the shared additive synth, and hand the result to a single AudioSink. There is no live/streaming audio-mixing capability anywhere in the engine. Naively, “let the player mute individual MIDI tracks live” sounds like it needs one — re-mixing the active subset of tracks into a fresh buffer every time the player clicks a mute button, mid-playback.

It doesn’t, and this is the key design insight: mute doesn’t require re-mixing if every track is already an independent, complete render, played as its own simultaneous sink. Muting one track is then just setting that sink’s volume to zero — an operation bevy_audio supports natively per-sink, with no re-rendering, no re-encoding, and no new streaming infrastructure at all.

Why each sink is tagged MusicPlayer and MidiTrackPlayer

MusicPlayer is the ordinary, pre-existing marker component that tags “the currently-playing background-music entity” for pause/resume and global-volume-slider application (gameplay::lifecycle:: apply_music_volume, gameplay::pause_menu) — code written for the single-sink case, years before multi-track backing existed. Tagging every per-track sink with MusicPlayer too (alongside the new, per-track MidiTrackPlayer(usize)) means that existing pause and global-volume code needs zero changes to correctly apply to N sinks instead of one — it already iterates every matching entity, never assumed there’d be exactly one. The two places in the codebase that do assume a single sink (Query::single(), in gameplay::clock’s wait-for-note pause/play check and A–B loop boundary handling) simply see “zero or multiple” as the same “nothing to anchor to” case they already handle gracefully for a music-less song — and neither code path is reachable in Jam Session anyway (no SongNotes, no A–B loop UI there), so this was verified safe rather than assumed safe.

MidiTrackPlayer(usize) itself lives in gameplay::state, not in jam::midi_tracks where it’s read — the same “define shared low-level vocabulary where the lower layer needs it, not where the higher one reads it” placement MusicPlayer itself already follows (countdown_overlay, which spawns the component, can’t depend on jam without inverting the dependency direction the section above described).

Why mute always wins over a global volume change

apply_midi_track_mute is ordered .after(gameplay::lifecycle:: apply_music_volume) (see The Plugin Architecture for what this ordering primitive means). Both systems can write the same sink’s volume in the same frame — dragging the Options volume slider fires apply_music_volume, which sets every MusicPlayer sink (per-track ones included) to the new global level, which would silently un-mute a muted track if nothing ran afterward to re-assert it. The explicit ordering constraint is what guarantees mute always has the last word, deterministically, rather than depending on whichever order Bevy’s scheduler happened to pick that frame.

The UI: one shared observer, not N closures

The per-track mute row (jam::midi_tracks::spawn_midi_track_row) spawns one button per track, each tagged TrackMuteCell(index), all sharing one toggle_track_mute observer function (cloned onto every button’s entity) rather than a distinct closure capturing a different index per button:

This is the same mechanical pattern gameplay::harmonica_overlay:: DiagramCellTarget already established for the Bending Trainer’s selectable diagram cells — a reusable idiom in this codebase for “N dynamically spawned, independently clickable things sharing one piece of click-handling logic,” worth recognizing as a pattern rather than reinventing per feature.

Looping preserves mute state for free

restart_finished_jam_music (which re-spawns every track’s sink together once the previous set has fully finished, when Loop is on) doesn’t touch JamMidiMute at all — it’s a resource independent of any particular sink’s lifetime, so a track muted before the loop boundary stays muted after new sinks spawn, with no explicit hand-off code needed.

Improv and call-and-response

Two smaller jam submodules round out free-play practice, both built on ImprovStats/ActivePitches (the same live-pitch data scoring consumes — see The Scoring System) without scoring anything themselves:

  • jam::improv — accumulates scale/chord-tone/phrase-discipline adherence continuously during any jam (an “always-on diagnostic,” the same convention SongStats::clean_attack follows in real gameplay), which several Lessons pass criteria read from without Jam Session needing to know Lessons exists at all — see The Lessons Engine.
  • jam::call_response — freeform, unscored call-and-response: the game plays a short generated lick (rolled from harp-producible chord tones of the current bar) and gives the player a couple of bars to echo it by ear, with purely visual turn-taking feedback (a banner, the lick’s holes ghost-highlighted on the hole map) — deliberately no PitchGate/ImprovStats involvement, since there’s no authored target to judge against, just a suggestion.

The Lessons Engine

The Lessons module (src/lessons/) is Harmonicon’s guided curriculum: a tree of prerequisite-gated lessons, each judged by one of a small, closed set of pass criteria, with progress persisted per player. This chapter covers the manifest format, how a lesson actually runs (mostly by reusing the ordinary gameplay pipeline, with one deliberate exception), and how discovery works for both bundled and player-dropped lesson content.

LessonManifest: the authored content

A lesson is a lesson.json file (assets/lessons/<unit>/<lesson>/, schema-validated against assets/lesson_schema.dtd.json), with a stable id (referenced by other lessons’ prerequisites and by PlayerProfile’s progress records — ids are never meant to change once published) and:

  • title_key/body_key — Fluent message keys, never raw display text. This is a hard rule, not a style preference: it’s what lets a lesson’s text be properly localized, and it means the Song Editor’s own lesson-authoring UI (see The Song Editor) can never accidentally write real display text into the manifest — serialize_lesson derives the keys from the lesson id and prints the key/text pairs to add by hand to the locale files, the same manual step every bundled lesson’s authoring already requires.
  • chart — optional. Present for a lesson backed by an ordinary .harpchart (played through the unmodified gameplay pipeline — see below); absent for the handful of open-ended, jam-based lessons.
  • prerequisites — a list of other lesson ids that must be passed first.
  • pass_criteria — a small closed enum (not open-ended scripting): Accuracy { threshold }, Technique { technique, threshold }, or one of three jam-based criteria (ScaleAdherence, ChordToneAdherence, PhraseDiscipline) for open improvisation lessons with no fixed notes to score.

Prerequisite gating is a pure function

is_unlocked takes the manifest and an already-resolved list of passed lesson ids — it doesn’t reach into PlayerProfile itself. This keeps it trivially unit-testable against plain data (see Testing Strategy) and, more importantly, keeps lessons::manifest ignorant of how progress is actually stored — the same “low-level module doesn’t depend on the higher-level thing that uses it” direction this codebase applies consistently (see Module Boundaries and Dependency Rules).

Running a lesson: mostly the ordinary pipeline, with one exception

A chart-backed lesson plays through the exact same pipeline as an ordinary songPlay2D/Play3D, the same SongChartLoader, the same score_notes. There is no lesson-specific scoring path. What changes is layered on top, via a LessonContext resource kept in flight for the run’s duration: the results screen judges pass_criteria against it instead of (or alongside) recording an ordinary song-best, adaptive difficulty is forced off (a lesson’s own pacing shouldn’t be further modulated by a second, unrelated pacing system — see Application States and Modes for the general routing-flag pattern LessonContext follows for “where do I land when this run ends”), and the menu routes back to the lesson list rather than the song list on exit.

The one exception: PassCriteria::ScaleAdherence/ ChordToneAdherence/PhraseDiscipline have no chart notes to score at all — they’re an open GameplayMode::JamSession run judged on live adherence data jam::improv::ImprovStats was already accumulating continuously (see Jam Session), with a dedicated “Finish Lesson” pause-menu button (visible only when a LessonContext is in flight during a jam) that judges the accumulated stats on demand, since there’s no natural chart end to trigger judging automatically the way a scored song has.

Discovery: bundled plus external, kept live

lessons::catalog::scan_all_lessons scans assets/lessons and then, if present, ~/Harmonicon/lessons — bundled entries first, so a player-dropped lesson can never silently reorder or shadow shipped curriculum. This mirrors assets_management’s own bundled-plus-external pattern for songs and themes exactly (see Persistence for the shared live-filesystem-watcher infrastructure both ride on) — deliberately: lessons depends on assets_management for the low-level watch machinery, never the other way around, since assets_management is generic shared vocabulary that has no business knowing what a “lesson” is. A live drop-in under ~/Harmonicon/lessons fires a LessonsRescanned message the Lessons list page consumes to rebuild itself if it happens to already be open — no restart, no manual refresh button.

Progress

lessons::progress judges a finished run (Accuracy/Technique thresholds against results::accuracy/SongStats; the jam-based criteria against ImprovStats) and the result is written into PlayerProfile (see Persistence) as a passed/not-yet- passed record keyed by lesson id — the same profile file per-song best scores and Bending Trainer drill records already live in, not a separate lessons-specific save file.

Localization and Theming

Localization and theming are two separate subsystems (localization.rs and theme.rs), but they’re covered in one chapter because they share both a common shape — data loaded from files, resolved against a player preference, applied reactively when that preference changes — and a common history: both were reworked during the same push to get Harmonicon running under WebAssembly, for the same underlying reason (a wasm AssetReader fetches over HTTP and simply cannot list a directory the way a native filesystem can), which makes them a useful paired case study in why an asset-loading design choice that works fine natively can quietly fail to generalize to a second target platform.

Localization: enforcement, then loading

Every user-visible string must come from loc.msg("key") (or loc.msg_args for one with interpolated values), never a raw string literal. This isn’t a style guideline enforced by review discipline — it’s enforced by the build itself. build.rs statically scans every source file for a handful of known “sink” shapes a raw string could reach the screen through (Text::new("..."), a bsn! Text({"..."}) binding, a handful of shared label-spawning helpers) and fails the build if it finds a literal that looks like natural-language text (a simple two-feature heuristic: contains an ASCII letter and whitespace — this deliberately doesn’t flag a single word like "Retry", which is a much larger, separate content-migration effort). A LocalizedStr newtype wraps every already-localized string so a value that’s passed through several layers before display still carries the “this came from loc.msg” guarantee with it.

Why loading is a fixed list, not a directory scan. The natural way to load “every locale we ship” would be AssetServer::load_folder ("locales") — but that needs the asset reader to list the directory’s contents, which bevy_asset::io::wasm::HttpWasmAssetReader cannot do over plain HTTP. This used to hard-panic the game on startup under wasm (bevy_fluent’s bundle builder indexing an empty map, since load_folder silently found nothing). The fix: localization::LOCALES is a fixed, three-element array of language tags, each loaded by an explicit path (locales/<lang>/main.ftl.ron) — no directory listing involved at all, so it works identically on native and wasm. A unit test, locales_const_matches_the_assets_directory, keeps the constant honest against what’s actually on disk (using a real std::fs::read_dir — safe there specifically because tests always run on the native host, never inside the wasm build itself).

This “fixed list instead of a directory scan” fix generalizes cleanly here because the set of shipped locales is genuinely small and rarely-changing — which is exactly the assumption that stops working for the next section.

Theming: names via a build-time manifest, content via a real Asset

Themes have the same directory-listing problem localization did, but a critically different shape: while there are only three fixed locales, a player can drop an arbitrary number of new songs, themes, and harmonica models into ~/Harmonicon on native without a rebuild — so assets_management’s song/theme/note-theme/harmonica-model discovery cannot become a fixed compile-time list the way LOCALES did, without breaking that entirely. This is the one place the localization fix’s own pattern had to be rejected, deliberately, rather than reused:

assets_management’s scan functions (scan_all_songs, scan_note_themes, scan_harmonica_models, scan_ui_themes) are each two #[cfg]-gated implementations under the same name: the original std::fs::read_dir-based body, completely unchanged, behind #[cfg(not(target_arch = "wasm32"))]; and a #[cfg(target_arch = "wasm32")] sibling that reads a manifest build.rs generated at build time instead (generate_wasm_asset_manifest, included via include!(concat!(env!("OUT_DIR"), "/asset_manifest.rs"))). The insight this rests on: a build script always compiles for, and runs on, the native host, no matter what --target the crate itself is being built for — so build.rs can do a completely ordinary std::fs::read_dir walk of assets/ even while producing a wasm32 binary, mirroring each scan function’s own discovery rule exactly (the first *.harpchart under a song’s song/ subfolder, and so on) so the two implementations can’t silently drift apart. Native behavior is exactly unchanged — a player can still drop a new song into ~/Harmonicon/songs/ and see it without a rebuild — because the #[cfg(not(wasm32))] body never went away; only wasm, which has no concept of “drop a file into a folder on the machine running the browser” in the first place, gets the build-time-baked alternative.

Theme content, not just theme names, has a second, distinct loading problem. Even once AvailableThemes correctly lists theme names under wasm, actually applying a theme still needs to read that theme’s theme.json — and the original theme::load_theme did that with a raw std::fs::read_to_string, which fails identically under wasm (a different mechanism than a directory listing: an actual file read, not an enumeration). The fix here mirrors the chart loading pipeline described in Chart Format and Asset Loading rather than the manifest trick above: ThemeJson is now a real Bevy Asset, loaded through a small custom AssetLoader (ThemeJsonLoader, matching song::loader::SongChartLoader’s shape — registered by the compound extension "theme.json", not the bare "json", so it can never collide with some other JSON asset gaining its own loader later) — which works identically on native and wasm because AssetServer itself already abstracts over “read this file,” the same way it already did for a theme’s sibling images and sounds.

This changed load_theme from one synchronous function into two systems — request_theme_load (kicks off the load, clears the previous theme’s data immediately) and apply_theme_when_loaded (polls the handle every frame, a no-op whenever nothing is pending, and populates LoadedTheme once the load resolves) — because an AssetServer load is inherently asynchronous; there’s no synchronous “just get me the bytes” escape hatch that would also work under wasm.

The shared lesson

Both fixes are instances of the same underlying principle, applied differently based on one question: does this data need to change at runtime without a rebuild? Where the answer is no (three fixed locales), bake it in at build time and load by explicit path. Where the answer is yes but only on native (arbitrary player-dropped content), keep the real runtime scan on native and bake in a build-time-computed equivalent for wasm specifically, leaving native completely untouched. Where the thing being loaded is genuine file content, not just a listing of what exists, route it through AssetServer like any other asset rather than reaching for std::fs directly — AssetServer was already built to abstract over exactly this platform difference, for every asset type that goes through it correctly. See Native vs. WebAssembly for the fuller picture of what’s ported to wasm today and what remains (mic capture, settings/ profile persistence, the external-folder watcher).

Persistence

Harmonicon persists three distinct kinds of state to disk, each with a deliberately different save strategy, plus a live filesystem watcher that makes one particular directory (~/Harmonicon) behave like a content source the game keeps in sync with, not just a place it reads from once at startup. This chapter covers all three save paths and the watcher.

Settings vs. profile: two save strategies for two access patterns

settings.rs (AudioSettings and friends — volume levels, the chosen pitch algorithm, input device, latency calibration, UI theme, adaptive difficulty on/off) and profile.rs (PlayerProfile — per-song best scores, per-technique best accuracy, Bending Trainer drill records, total play time) both persist to JSON under the platform config directory — harmonicon_platform::paths::config_dir, the single answer to “where do we write”, #[cfg]-split because Android has no such thing as an XDG config directory (dirs::config_dir() returns None there, so every save silently no-opped and all progress was lost on exit; the sandbox path comes from AndroidApp::internal_data_path() instead). Loading goes through figment, layered so a fresh install gets sensible defaults without a config file needing to exist yet, but they save on opposite schedules, matched to how often each actually changes:

Settings are debounced because a slider drag can fire many change events per second — writing a file on every single one would be wasteful disk I/O for no benefit, since only the final value after the player stops dragging actually matters. PendingSave restarts a 0.5-second countdown on every change; tick_debounce writes once it elapses with no further changes in the meantime, and AppExit flushes unconditionally so a change made right before quitting is never silently lost to an in-flight debounce that never got to fire.

The profile deliberately has no debounce machinery at all. A new best score or drill record is inherently a rare, discrete event (once per song completion at most, not many times a second), so there’s nothing to batch — writing immediately, at the exact point the record changes, is both simpler and loses no more data on an unexpected exit than debouncing would. The one thing that does accumulate continuously — total play time — is the one field flushed on AppExit rather than written continuously, for the ordinary reason: nobody wants a disk write every frame for a number nobody’s watching in real time.

~/Harmonicon: a second asset root, watched live

Beyond the bundled assets/ tree, Harmonicon registers a second, optional AssetSource (external://, mapped to ~/Harmonicon — see The Plugin Architecture for why this has to be registered before DefaultPlugins) so a player can drop in their own songs, themes, and lessons without touching the install directory at all — and, going a step further, without even restarting the game.

A few design choices here are worth calling out:

  • The watcher module itself is agnostic of what any subfolder means. assets_management::watch fires one generic ExternalFolderChanged{top_level_dirs} message naming which immediate subfolders changed, without knowing or caring that songs means something to assets_management and lessons means something to a completely different module. lessons::catalog is its own, independent consumer of the same message — a lessons-depends-on- assets_management edge, never the reverse, since assets_management is meant to be generic, low-level shared vocabulary (see Module Boundaries and Dependency Rules).
  • A dedicated *Rescanned message, not just “the resource changed.” A menu page reacting to resource_changed::<AvailableSongs> would also see it as “changed” on the ordinary one-time Startup scan, and again every time the page re-enters and its own change-detection tick happens to fall after some unrelated write — SongsRescanned/ ThemesRescanned/LessonsRescanned fire only when a live watcher event actually triggered a re-scan, which is the one distinction a menu page genuinely needs (“did something new just appear while I was sitting here” vs. “this resource simply exists”).
  • Every scan function fully replaces its resource’s contents, rather than appending — making every one of them safe to call a second time at runtime, not just once at Startup. This wasn’t always true (some scan functions used to only ever run once and assumed that), and fixing it was a prerequisite for live rescanning to be correct at all: an appending scan run twice would duplicate every previously-found entry.
  • Deliberately not built on Bevy’s own asset-hot-reload path. Bevy can watch and hot-reload already-loaded assets, but that’s useless here specifically because the content this watcher cares about was often never loaded in the first place — a brand-new song the player just dropped in has no existing Handle for Bevy to reload. Separately, whether Bevy’s own watching is on at all is one global flag applied uniformly to every registered AssetSource — enabling it for external:// would also silently enable asset hot-reloading for the bundled assets/ tree in shipped builds, which is explicitly a --features dev-only behavior everywhere else in the project.

None of this watcher infrastructure exists under wasm — there’s no concept of a home directory, let alone a filesystem to watch, inside a browser sandbox. See Native vs. WebAssembly for what that means concretely (nothing wasm-specific was needed; the native code already handles dirs::home_dir() returning None gracefully) and for the persistence gap that is still open there (settings/profile storage has no browser-compatible replacement yet).

Native vs. WebAssembly

Harmonicon’s primary target is a native desktop build, but the crate also compiles for wasm32-unknown-unknown and boots and runs in a real browser, verified with headless Chromium. This chapter is the consolidated picture of what that involved: the build pipeline itself, the dependency conflicts that had to be resolved to compile at all, the asset-loading rework Localization and Theming and Chart Format and Asset Loading cover in depth from their own angles, a genuine GPU-shader compatibility bug the wasm push surfaced, and — importantly — an honest list of what still doesn’t work in a browser.

The build pipeline

Trunk (a Rust/Bevy-ecosystem-standard wasm bundler) drives the wasm build from index.html and Trunk.toml at the repository root — it compiles the crate for wasm32-unknown-unknown, runs wasm-bindgen to generate the JS glue, and copies assets/ alongside the output. The <canvas id="bevy-canvas"> element index.html declares is wired up in lib.rs’s WindowPlugin (canvas: Some("#bevy-canvas".into()), fit_canvas_to_parent: true, prevent_default_event_handling: true) — all three fields are documented no-ops on native, so no #[cfg] is needed around setting them unconditionally.

Two trunk serve traps, both fixed in Trunk.toml

Both produced a blank page with a working build, so neither points at itself. Both are configuration, not code — trunk build --release plus any static file server was unaffected the whole time, which is what makes them so slow to find.

  • [serve] no_spa = true. Trunk’s SPA fallback answers every missing path with index.html and a 200. Bevy’s AssetServer probes for an optional <asset>.meta sidecar next to each asset; none exist here, so instead of the 404 it expects (meaning “no meta, use defaults”) it gets HTML with a success status, fails to deserialize it as meta, and marks the asset failed. Localization is the fatal one — localization_ready never flips, so the app sits in AppState::Startup, which draws nothing but the clear colour. The tell is Failed to deserialize meta for asset ... in the browser console.
  • [watch] watch = [...]. Trunk otherwise recursively watches the whole repo root. target-flatpak/var/run is a symlink to the real /run, so the walk descends into root-only directories like /run/udisks2 and trunk serve aborts before building anything with failed to watch ... Permission denied. Listing the source paths explicitly keeps the watcher out of every build-output tree.

Getting it to compile at all: two dependency conflicts

Two unrelated dependency issues blocked a wasm32 build before any actual feature work could begin, both worth knowing about since they’ll resurface if a dependency bump reintroduces either:

  • Two incompatible major versions of getrandom in the tree at once — 0.4.x (pulled in via rand/Bevy) and 0.3.x (via ahash) — and both refuse to target wasm32 without an explicit opt-in. Cargo.toml’s [target.'cfg(target_arch = "wasm32")'.dependencies] section enables both explicitly (the 0.3.x line under a renamed package alias, getrandom03, since both can’t share the plain getrandom name in one Cargo.toml), and the 0.3.x line additionally needs a compile-time --cfg the Cargo feature alone doesn’t cover — hence the RUSTFLAGS='--cfg getrandom_backend="wasm_js"' in every wasm build/check command. This isn’t set globally in .cargo/config.toml because that file is this repository’s per-machine, gitignored local build config (linker overrides, sccache), not somewhere to route a project-wide setting through.
  • jsonschema’s default features pull in reqwest, which explicitly refuses to compile for wasm32 at all (resolve-http/resolve-file, used for resolving a schema’s remote $refs — a capability nothing in this codebase’s schema validation actually uses, confirmed before disabling). jsonschema = { version = "0.28", default-features = false } fixes this with no behavior change on native either, verified by the full native test suite still passing unchanged.

The asset-loading rework

Covered in depth in their own chapters — this is the index:

  • Localization and Theming: the bevy_fluent startup panic (load_folder needing directory listing), fixed with a fixed LOCALES list loaded by explicit path; and theme::load_theme’s raw std::fs::read_to_string, fixed by turning ThemeJson into a real AssetServer-loaded Asset.
  • Chart Format and Asset Loading / Persistence: assets_management’s song/theme/ harmonica-model discovery, fixed with a #[cfg]-gated pair — native keeps its real std::fs::read_dir scan unchanged, wasm reads a build.rs-generated manifest instead (built at build time, on the native host, regardless of the crate’s own --target).

A genuine bug the wasm push surfaced: WebGL2 uniform alignment

Not every issue wasm exposed was a directory-listing problem. Once theme loading actually started working under wasm (rather than failing silently before that fix landed), the game reached, for the first time, a code path that actually used a custom WGSL shader material — the themed buttons’ animated smoke-shader background — and immediately hit a real GPU pipeline-creation error:

Desktop/native rendering backends tolerate a smaller-than-16-byte uniform buffer binding just fine; WebGL2’s wgpu “downlevel” backend (used when a browser doesn’t expose WebGPU, still the common case) does not, and this shader’s time: f32 uniform was 4 bytes. This wasn’t a new bug introduced by the wasm work — it was always broken for any browser without WebGPU, just never discovered, because the earlier theme-loading failure had been silently preventing this code path from ever running under wasm at all. This is worth internalizing as a general lesson about cross-platform testing: fixing one bug can be exactly what it takes to reveal the next one behind it, and “it worked when I tested wasm” can mean “the feature I was testing never actually ran,” not “the feature works.” The project’s own resolution was to remove the smoke-shader button effect entirely rather than pad the uniform to 16 bytes and keep it — a legitimate call given the effect’s actual value versus the ongoing WebGL2-compatibility maintenance burden of every future shader touching this material.

What still doesn’t work in a browser

Verified via headless Chromium (checked for zero panics across a full run): WGPU initializes, localization loads, mic capture fails gracefully exactly like a real permission-less browser would (MicStatus::Failed, no panic — cpal simply has nothing to talk to under wasm), and bundled songs/themes/note-themes/harmonica-models all load correctly. What’s explicitly still missing, none of which have a drop-in browser equivalent to reach for:

  • Actual microphone input. The whole point of the game — cpal has no wasm backend; a real implementation needs a Web Audio API bridge (AudioContext/MediaStreamAudioSourceNode, called through wasm-bindgen/web-sys), feeding the same pitch_detect::analyze pipeline The Audio Input Pipeline describes — the pipeline’s analysis side needs no change at all, only the capture side needs a second implementation.
  • Settings and profile persistence. figment/dirs-based JSON files have no meaning in a browser sandbox; a wasm build would need something like localStorage or IndexedDB instead — see Persistence.
  • The ~/Harmonicon external-folder watcher. No home directory concept in a browser at all — the native code already handles dirs::home_dir() returning None gracefully (no external songs/ themes/lessons, no watcher started), so nothing wasm-specific was needed to avoid a crash here; a real wasm equivalent (letting a player add their own content some other way — a file picker, drag-and-drop into the page) is unexplored.

Each of these three is a real, standalone piece of engineering — not a loading-order fix like the ones this chapter otherwise covers — and each would benefit from its own design discussion before being started, per this project’s own working practice of not guessing at scope for a large, undesigned subsystem.

Android

The Android port builds a real, installable APK and runs. This chapter is the architectural half — the shape the port forced on the codebase, and the traps that cost the most time. For build commands, emulator setup and the current verified/unverified split, see Building and Running on Android.

Android inverted the entry point

Android never calls a main. The platform loads a shared object and calls android_main, handing over an AndroidApp that owns the event loop and the JNI handles. That single fact reshaped the workspace root.

The composition root moved out of src/main.rs into src/lib.rs’s run(), so both entry points are thin wrappers around one shared assembly:

The cdylib is its own crate rather than a second crate-type on the root package, because default-members includes every workspace member: a cdylib on the root would relink the entire Bevy app on every desktop cargo build. Instead harmonicon-android’s dependency on the game is target-gated and its lib.rs is entirely #[cfg(target_os = "android")], so off Android it compiles to an empty cdylib with no dependencies — measured at 4.2 MB with zero Bevy symbols, against a 517 MB desktop binary.

harmonicon-android is therefore the one crate that sits above the root package in the layering, and the only cdylib.

Assets live inside the APK

An APK’s assets are inside the archive, reachable only through the JNI AssetManager. std::fs::read_dir("assets/songs") returns Err, so the runtime scans find nothing at all.

This is the same constraint wasm already had, so Android reuses the same solution — the #[cfg]-split scan functions backed by a build.rs-generated manifest described in Chart Format and Asset Loading and Native vs. WebAssembly. The predicate widened from wasm32 to any(target_arch = "wasm32", target_os = "android").

The framing that matters: the condition is “this target’s assets/ is not a readable local directory”, not “this target is not desktop”. iOS is deliberately excluded — an app bundle’s Resources directory reads like any other, so iOS keeps the runtime scan and the ~/Harmonicon drop-folder dynamism that comes with it.

Doing this surfaced that lessons were broken on wasm too: lessons::catalog had no manifest path at all, because it reads each lesson.json’s bytes directly rather than through AssetServer. It needed its own build script (OUT_DIR is per-package) embedding the JSON text with include_str!, not just directory names. Fixing Android fixed the web build’s silently-empty Lessons menu.

Two failures that only appear at runtime

Both compiled cleanly, packaged cleanly, and passed every static check on the APK. This is the argument for keeping an emulator in the loop rather than trusting a green build.

ClassNotFoundException for GameActivity — while the class was in classes.dex. The real cause hid in a suppressed exception: NoClassDefFoundError: AppCompatActivity. GameActivity extends it, but games-activity’s POM declares no dependencies at all, so appcompat was never pulled in transitively.

NoSuchMethodError on Application.requestPermissionsndk_context::android_context() is the obvious way to reach the app context from Rust and is wrong for this: android-activity registers the Application there, not the Activity. Application is a Context, so checkSelfPermission resolves and appears to work, while requestPermissions — declared on Activity — throws. The Activity comes from AndroidApp::activity_as_ptr.

That one also cascaded: a throwing JNI call leaves the exception pending on the thread, so every later call fails with the same opaque “Java exception was thrown”, once per frame, never showing the cause. permission.rs’s with_activity now calls exception_describe/exception_clear, which is what surfaced the real error in logcat.

Version and API constraints that fail late

  • The GameActivity AAR version is pinned to the C++ vendored in the Rust crate. android-activity’s GameActivity.h declares version 4.4.0, so Gradle pins androidx.games:games-activity:4.4.0. A mismatch aborts in RegisterNatives at runtime.
  • API 28 is a hard floor. cpal links libaaudio, which only exists in the NDK sysroot from API 26 up; below it the link fails with a bare unable to find library -laaudio.
  • The Android-only Bevy feature selection lives in harmonicon-android’s own Cargo.toml, not the root package, so cargo ndk -p harmonicon-android keeps it. On the root package, -p silently drops it and you get a build with no activity backend.

Persistence had to move

dirs::config_dir() returns None on Android — an app has no XDG config directory, only a sandbox — so every save silently no-opped and all progress vanished on exit. See Persistence for harmonicon_platform::paths::config_dir, the #[cfg]-split single answer to “where do we write”.

What the port has not established

It runs on an emulator. Nobody has played a harmonica into a phone, and that is the whole product: an emulator opening a capture stream says the plumbing is connected, not that pitch detection survives a phone mic’s AGC and noise suppression. Touch gestures are likewise emulator-only — and can’t be scripted there, since adb shell input has no multi-touch.

Building and Running on Android

The operational half of the Android port — how to build it, how to run it on an emulator, and exactly how far it has actually been taken. For the shape the port forced on the codebase, see Android.

Status: runs on an emulator; never run on real hardware

Verified, by actually running it on an Android 15 (API 35) x86_64 emulator:

  • It launches, and the main menu renders correctly — background art, fonts, all four buttons.
  • Assets load out of the APK. That background is a theme asset, so the build-time manifest path works in a real AssetManager environment.
  • The permission flow works end to end: the system GrantPermissionsActivity dialog appears, and once the permission is granted the polling retry picks it up and opens a capture stream — Input device : Default Device / Sample rate : 44100 Hz | channels: 2 | format: F32.
  • The APK’s contents were also inspected statically: cdylib exporting android_main and GameActivity_onCreate, GameActivity in classes.dex, RECORD_AUDIO declared, 186 asset entries, debug_songs excluded.

CI’s android_check job type-checks the target on every push.

Not verified. No real device, so: nobody has played a harmonica into it. An emulator opening a capture stream says the plumbing is connected; it says nothing about latency, gain, or whether pitch detection works against a phone mic’s AGC and noise suppression — which for this game is the whole product. Also unknown: touch target sizes, whether landscape-only is right, real frame rates, and whether the Song Editor is usable on a phone at all.

Two bugs were found only by running it, both since fixed — see “Two runtime-only failures” below. Neither was visible at build time.

Persistence

dirs::config_dir() returns None on Android — an app has no XDG config directory, only a sandbox — so every save used to silently no-op and all progress was lost on exit.

harmonicon_platform::paths::config_dir is now the single answer to “where do we write”, #[cfg]-split: dirs on desktop, AndroidApp::internal_data_path() (via bevy’s ANDROID_APP) on Android. Both settings.json and profile.json go through it.

Verified on the emulator: settings.json appears in /data/data/io.github.tcanabrava.harmonicon/files/, and corrupting it makes the app log “Could not read settings” on the next launch — so both directions are wired, not just the write.

Building

export ANDROID_HOME="$HOME/Android/Sdk"
cd packaging/android
./gradlew assembleRelease          # -> app/build/outputs/apk/release/app-release.apk
./gradlew installRelease           # with a device connected via adb

Requires the SDK (platform 35, build-tools 35.0.1), NDK 28.2.13676358, a JDK 17+ (Android Studio’s bundled jbr works), and cargo-ndk. Gradle comes from the committed wrapper.

If cargoNdkBuild fails with “found crate … compiled by an incompatible version of rustc”, it is almost certainly a stale Gradle daemon. The daemon captures its environment (including PATH) when it starts and reuses it for every later build, so a PATH change — or picking up a second cargo from ~/.cargo/bin that differs from the system one — keeps biting long after you fixed it in your shell. ./gradlew --stop and build again. If your SDK isn’t at $ANDROID_HOME, put sdk.dir=/path/to/sdk in packaging/android/local.properties (gitignored).

The Gradle build invokes cargo ndk itself — there is no separate Rust step to remember. Expect ~6 minutes for a cold Rust release build.

Other ABIs, and the emulator

Only arm64-v8a is built by default: every phone worth targeting is arm64, and each extra ABI costs another full Rust build plus ~108 MB of APK. A desktop emulator is x86_64, so it needs an override:

./gradlew installRelease -Pharmonicon.abis=x86_64

Comma-separate for a fat APK (-Pharmonicon.abis=arm64-v8a,x86_64).

Running it on the emulator

One-time setup, if the AVD doesn’t exist yet:

sdkmanager "emulator" "system-images;android-35;google_apis;x86_64"
avdmanager create avd -n harmonicon-test \
    -k "system-images;android-35;google_apis;x86_64" -d pixel_6

Then, each time:

export ANDROID_HOME="$HOME/Android/Sdk"
export JAVA_HOME=/opt/android-studio/jbr          # or any JDK 17+
export PATH="$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH"

emulator -avd harmonicon-test &                   # add -gpu host if it's slow
adb wait-for-device

cd packaging/android
./gradlew installRelease -Pharmonicon.abis=x86_64

adb shell am start -n \
    io.github.tcanabrava.harmonicon/com.google.androidgamesdk.GameActivity

Tap Allow on the microphone prompt (or adb shell pm grant io.github.tcanabrava.harmonicon android.permission.RECORD_AUDIO — the polling retry picks either up within a frame or two).

Do not pass -no-audio if you want to test the mic: the emulator forwards the host’s input device, which is how the capture stream gets a real signal. Headless (-no-window) is fine for checking it boots.

Watch the game’s own output with:

adb logcat | grep -E "RustStdoutStderr|harmonicon"

Bevy’s LogPlugin filter is warn by default, so info! lines don’t appear; println! does, via RustStdoutStderr.

Two runtime-only failures

Both compiled cleanly, packaged cleanly, and passed every static check on the APK. Only launching it found them. This is the argument for keeping an emulator in the loop.

ClassNotFoundException: com.google.androidgamesdk.GameActivity

The class was in classes.dex — the real cause was hidden in a suppressed exception: NoClassDefFoundError: androidx/appcompat/app/AppCompatActivity. GameActivity extends AppCompatActivity, but the games-activity-4.4.0.pom declares no dependencies at all, so appcompat was never pulled in transitively. It has to be an explicit implementation("androidx.appcompat:appcompat:...").

That also forces the theme: AppCompatActivity refuses to start under a plain platform theme, so @android:style/Theme.NoTitleBar.Fullscreen had to become a Theme.AppCompat.NoActionBar descendant (res/values/themes.xml).

NoSuchMethodError: Landroid/app/Application;.requestPermissions

ndk_context::android_context() is the obvious way to reach the app’s context from Rust, and it is wrong for this: android-activity registers the Application there, not the Activity (its init.rs, initialize_android_context(vm, app_global)).

Application is a Context, so checkSelfPermission resolves on it and appears to work — but requestPermissions is declared on Activity, so it threw. The Activity has to come from AndroidApp::activity_as_ptr, which Bevy keeps in ANDROID_APP.

The failure also cascaded: a throwing JNI call leaves the exception pending on that thread, so every later call failed with the same opaque “Java exception was thrown” — one bad call per frame, and the real cause never shown. with_activity now calls exception_describe/exception_clear, which is what surfaced the actual NoSuchMethodError in logcat.

Decisions worth knowing

GameActivity, and why its version is not free to choose

android-activity requires exactly one backend and Bevy 0.19 selects neither by default (its defaults are just 2d/3d/ui/audio), so this had to be chosen explicitly. GameActivity was picked over NativeActivity for its far better soft-keyboard/IME handling, which the Song Editor’s text fields need.

The Java AAR version must match the C++ vendored in the Rust crate. android-activity’s GameActivity.h declares GAMEACTIVITY_MAJOR/MINOR/BUGFIX_VERSION as 4/4/0, so the Gradle dependency is pinned to androidx.games:games-activity:4.4.0. A mismatch fails at runtimeRegisterNatives aborts the process — not at build time. Re-read those defines before bumping android-activity.

API 28 is a hard floor, not a preference

cpal’s Android backend links libaaudio, which only exists in the NDK sysroot from API 26 up. Below that the link fails with a bare unable to find library -laaudio that says nothing about why. minSdk in app/build.gradle.kts, the -P passed to cargo-ndk, and CI’s check all have to agree; 28 also comfortably clears the API 23 floor for the runtime permission API permission.rs calls.

The Android-only Cargo config lives in harmonicon-android

It used to sit on the root package, which meant cargo ndk -p harmonicon-android silently dropped it and produced a build with no activity backend. It now lives in the crate that is the Android target, so -p works. Keep it there.

Note cargo-ndk spells platform -P; -p is cargo’s package flag. Passing -p 28 gets you unknown package: 28.

No [package.metadata.android]

That block belongs to cargo-apk, which is deprecated and cannot emit a Play Store AAB. Gradle can (./gradlew bundleRelease), which is why the packaging went this way.

Asset discovery goes through the build-time manifest

An APK’s assets live inside the archive, reachable only through the JNI AssetManagerstd::fs::read_dir("assets/songs") returns Err, and the runtime scans would find nothing at all.

This is the same constraint wasm already had, so Android reuses the same solution: #[cfg]-split scan functions backed by a build.rs-generated manifest, now keyed on any(target_arch = "wasm32", target_os = "android").

  • Lessons were broken on wasm before this, not just on Android. lessons::catalog had no manifest path at all, because it reads each lesson.json’s bytes directly rather than through AssetServer. It now has one (crates/harmonicon-song/build.rs), embedding the JSON text with include_str!. Fixing Android fixed wasm.
  • iOS is deliberately excluded. An app bundle’s Resources directory reads like any other, so iOS keeps the runtime scan and the ~/Harmonicon drop-folder dynamism.

The microphone permission

RECORD_AUDIO is “dangerous”: declaring it in the manifest only makes it requestable, and until the user grants it, opening a cpal input stream fails indistinguishably from a broken device.

harmonicon-audio’s permission module calls Activity.checkSelfPermission/requestPermissions over JNI. audio_input::start_capture asks first and parks in MicStatus::AwaitingPermission — a state that already existed as groundwork, which the Options page already renders a banner for — and retry_capture_when_permission_granted polls until the dialog is answered. It polls because the result is delivered to an onRequestPermissionsResult callback on a Java activity this codebase doesn’t own, and a once-per-install dialog doesn’t justify routing that back across JNI.

Off Android, microphone_granted() returns true and request_microphone() does nothing, so call sites need no #[cfg].

android_main, and why the root package has a library

Android never calls main: the platform loads a shared library and calls android_main. That forced the composition root out of src/main.rs into src/lib.rs’s run(), which both entry points now call.

The cdylib is its own crate rather than a second crate-type on the root package because default-members includes every member — a cdylib on the root would relink the whole Bevy app on every desktop cargo build. Instead harmonicon-android’s dependency on the game is target-gated and its src/lib.rs is entirely #[cfg(target_os = "android")], so off Android it is an empty cdylib with no dependencies (measured: 4.2 MB with zero Bevy symbols, against a 517 MB desktop binary).

Size

147 MB APK: a 108 MB stripped cdylib (stored uncompressed so it maps straight out of the APK rather than unpacking a second copy into /data) plus ~38 MB of assets. Only arm64-v8a is built; adding armeabi-v7a means another full Rust build and roughly doubles the download. If this needs to come down, the first target is assets/themes (21 MB of the 38).

What has not been done

  • Never installed or launched. See the top of this file.
  • No touch-input pass. Keyboard-only actions already have on-screen equivalents, but nothing has been sized for a thumb.
  • No app icon — the APK currently has none (icon='').
  • Only arm64-v8a.
  • Release builds are debug-signed, so assembleRelease produces something installable without a keystore. Replace before distributing.
  • Opening the user guide does nothing. help_about::open_in_default_app returns Unsupported; doing it properly means handing an Intent to the system over JNI.
  • iOS remains untouched and needs Xcode.

Module Boundaries and Dependency Rules

Harmonicon has no Cargo-workspace boundaries between its subsystems (see System Overview for why it’s one library crate) — which means nothing at the compiler level stops any module from importing any other. The structure this chapter describes is enforced by a mix of one automated test, and — for everything the test can’t check — reviewer discipline against a written-down rule. This chapter states the rules, the one place they’re mechanically checked, and a documented exception worth understanding rather than working around.

Rule 1: unrelated things do not share a file

A file is one concern; its name says what that concern is; landing on a file via a grep hit should mean everything in it is relevant to what you were looking for. This is checked mechanically: tests/physical_design.rs::no_file_exceeds_the_line_budget_unless_ allowlisted enforces a ~1000-line budget on non-test code per file (test modules — #[cfg(test)] mod tests { ... } or a sibling tests.rs — are excluded from the count, and files literally named tests.rs are skipped as pure test content with no budget of their own).

The allowlist isn’t an escape hatch that quietly accumulates forever — a second test, allowlist_has_no_stale_entries, fails the build if an allowlisted file has already dropped back under budget, which is what makes the list function as an honest burndown chart of known, intentional debt rather than a ratchet that only ever grows. New code isn’t allowed to add itself to the list preemptively — the rule this enforces is “split before adding to an already-large file,” not “budget permission in advance.”

This rule has real teeth: a 2026-07 pass (docs/physical_design_plan.md) measured gameplay/mod.rs at 2,921 lines mixing plugin wiring, ~30 resource/component/message types, the score-state model, a 250-line scoring system, HUD updates, and 1,250 lines of inline tests (43% of the file) — and split it into the gameplay/ module structure described in The Scoring System and The Gameplay Clock today. The Song Editor’s own snap.rs (see The Song Editor) was split out of state.rs for exactly this reason, as recently as the same session that built the feature living in it — this isn’t a one-time historical cleanup, it’s an ongoing discipline applied as code is written.

Rule 2: folders match modules, and dependencies point downward

A module’s physical location should reflect its level: low-level shared vocabulary at the bottom, features in the middle, app-wiring at the top — and nothing should import upward. System Overview’s package diagram shows the intended shape; this section covers what “pointing the wrong way” actually looked like before it was fixed, as a concrete illustration of the rule rather than an abstract statement of it.

AppState used to live inside menu. Conceptually, an app-wide state machine is vocabulary every feature shares, not a menu concern — but historically it lived in menu/mod.rs, so gameplay (seven files), song_editor, spectrogram, and profile all had to use crate::menu::... to reach it, even though ten of the eleven things they were actually importing from there (AppState, GameplayMode, SelectedSong, ReturnToSongList) had nothing to do with menus at all. Anyone asking “what depends on the menu?” got a misleading answer, and any review of menu code pulled in readers who only ever wanted the state enum. The fix was mechanical once diagnosed: this vocabulary now lives in app.rs at the crate’s top level (see Application States and Modes), which every feature — menu included — depends on downward, and nothing depends on upward.

gameplay::call_response used to import song_editor::playback directly for its synth — two peer features welded sideways, when the synth (audio_system::synth, see The Audio Input Pipeline) is shared audio infrastructure with no real business living inside an editor tool. Moving the synth down to audio_system — vocabulary both gameplay and song_editor can depend on independently — removed the sideways edge entirely, rather than leaving one feature depending on the other’s internals.

The documented exception: composition roots

One place in the codebase looks, at first glance, like it violates “dependencies point downward” — and is worth naming explicitly as a deliberate, understood exception rather than either hiding it or mistaking it for a bug to fix:

gameplay::plugin — the one file responsible for assembling the entire AppState::Playing system schedule, across all three GameplayMode values — imports from jam to register Jam-Session-specific systems into that shared schedule. Read naively, that’s gameplay depending on jam, while Jam Session also describes jam’s own feature code depending on gameplay’s core primitives (GameplayClock, MusicPlayer) — which would be a real circular dependency, and a real problem, if both directions were the same kind of dependency. They aren’t: gameplay::plugin is acting as a composition root — the one place in the codebase whose entire job is wiring separately-developed pieces together — and a composition root being coupled to everything it composes is not the same failure mode as two peer features being coupled to each other’s internals. The rule this exception doesn’t violate: jam’s own feature logic never reaches into gameplay’s feature logic (2D/3D rendering, scoring) — only into the shared low-level vocabulary gameplay::state exists specifically to expose, the same primitives any other feature is free to depend on too.

The practical test for “is this a legitimate composition-root edge, or an actual layering inversion sneaking in”: does the dependency go from assembly/wiring code down into a feature’s own systems/resources (fine — that’s what a composition root does), or does it go from one feature’s own business logic sideways into another feature’s own business logic (the call_response/song_editor::playback case above — not fine, and the kind of thing worth flagging in review the same way the historical AppState-in-menu case would be today).

What isn’t enforced mechanically

The file-size budget is the one rule with a real, running test behind it. Dependency direction itself has no equivalent automated check today — a Cargo workspace with real crate boundaries would get one for free (an illegal use simply wouldn’t compile), which is the main thing a future workspace split, if the project ever grows to warrant one, would buy back over the current single-crate structure. Until then, this chapter — and a reviewer who’s read it — are the mechanism.

Testing Strategy

Harmonicon’s test suite (several hundred #[test] functions across src/, plus a handful of top-level integration tests in tests/) follows a consistent, project-wide convention: pure functions and unit tests first, the ECS system that drives them second — and, alongside the ordinary test suite, a set of build-time static checks that catch a specific class of bug no amount of unit testing would reach, because the bug isn’t in any function’s logic at all. This chapter covers both.

Pure functions and unit tests first

Wherever a new mechanic has any logic worth getting right — timing- window classification, tick-to-seconds conversion, snap-point selection, prerequisite gating, drift-correction math — that logic is written as a plain function taking plain data and returning plain data, with no World, Query, Res, or other ECS type anywhere in its signature, before the ECS system that calls it with real per-frame values exists. The Scoring System, The Gameplay Clock, The Song Editor’s snap functions, and The Lessons Engine’s is_unlocked are all examples this book has already covered in depth; the pattern repeats throughout the rest of the codebase at the same density.

The reason this ordering matters, not just “having both kinds of test” in the abstract: pure-function tests are fast, don’t need any Bevy scaffolding, and pin down the actual logic precisely — as the Song Editor chapter’s grid-snap case study shows, this is what makes it cheap to verify a change like “does snap_absolute_tick correctly wrap across a beat boundary” in isolation, without also standing up a World and simulating a drag gesture through it. ECS-level tests are reserved for what can only be verified with real Bevy machinery involved: system ordering, state-transition behavior, whether a resource actually gets initialized — using either a minimal World + Schedule (for a handful of systems in isolation) or a full App + relevant plugins (for state-transition-dependent behavior, menu/mod.rs’s and gameplay/tests.rs’s style).

Static checks that run at build time, not test time

A small number of correctness properties in this codebase are checked by build.rs itself — meaning a violation fails cargo build (and thus cargo test, cargo run, everything) before a single test even runs. Both exist because the failure mode they catch is a runtime panic, not a compile error, and one that’s easy to introduce without noticing and easy to miss until the exact code path happens to execute:

  • Localization enforcementbuild.rs scans every source file for a handful of known sink shapes (Text::new("..."), a bsn! literal binding, a fixed list of shared label-spawning helpers) and fails the build on a literal that looks like natural-language text. See Localization and Theming.
  • Message registration — every #[derive(Message)] type must appear in some .add_message::<T>() call somewhere in the codebase, or Bevy panics at runtime the first time a system’s MessageReader/MessageWriter for it actually runs — which can be well after the type was first added, since the type itself compiles fine unregistered. build.rs cross-references every declared message type against every registration call, statically. See The Plugin Architecture.

Both scans are intentionally simple, line-oriented text matching rather than a real parse of the Rust source (documented explicitly in build.rs’s own module comment, including the specific patterns each one can and can’t see through) — a deliberate scope trade-off: a real AST-based check would be more precise, but a purpose-built text scan for these two specific, narrow shapes is far less code, has no parser dependency, and in practice catches what it’s meant to.

Integration tests: schema validation and physical structure

Three top-level suites under tests/ check properties that span many files at once, which don’t naturally belong inside any single module’s own unit tests:

  • tests/asset_layout.rs — schema-validates every bundled song chart, theme, and lesson against their respective JSON schemas, and checks completeness (a lesson’s referenced chart file actually exists, a lesson’s prerequisite ids actually resolve to other real lessons, a lesson’s Fluent keys actually exist in every locale). This is what keeps bundled content — not code — from silently rotting as the schemas or the content itself evolve independently.

  • tests/physical_design.rs — the file-size budget enforcement described in Module Boundaries and Dependency Rules.

  • tests/glyph_coverage.rs — every character the game draws must exist in one of the bundled fonts. A character missing from all of them renders as a tofu box: it compiles, passes every other test, and is visible only in a rendered frame, in the right language. Five shipped that way in all three locales before this existed.

    It deliberately reads the font binaries’ cmap tables rather than dialogs::font_fallback’s hand-maintained lists, because that list is a statement of intent — adding a codepoint to it without also re-subsetting the .ttf leaves the glyph exactly as missing. It covers both locale values and \u{...} escapes in source, since button icons live in source and a locale-only scan would miss every one of them.

    Three deliberate exclusions, each of which was a false positive while writing it: Bravura.otf counts as coverage (the notation staff draws SMuFL private-use codepoints with it), invisible formatting characters are skipped (bidi isolates have no glyph by design — localization:: strip_bidi_isolates names U+2068/U+2069 precisely to remove them), and nothing below U+2000 is checked.

Developer tools as their own kind of testing infrastructure

src/bin/ holds three small binaries, sharing the library crate (see System Overview), each existing specifically to make some kind of manual verification faster than it would be through the full game:

  • hole-editor — positions the clickable hole overlays on a 3D harmonica model, writing the same holes.json format gameplay_3d/bending_trainer read at runtime — a visual tool for content that would otherwise mean hand-editing pixel coordinates in a text editor and reloading to check them.
  • note-editor — a visual editor for the 2D/3D note-head tail layout configs (NoteThemeConfig/NoteCube3dConfig — see Chart Format and Asset Loading).
  • note-bench — an offline pitch-detection benchmark: replays a “debug recording” (raw captured mic audio plus the chart and detection metadata, dumped by the Song Editor’s own --features dev “Debug Recording” checkbox) through each of the five selectable algorithms (see The Audio Input Pipeline) and reports a hit/ miss/phantom summary. Its comparison logic lives in the library (note_bench.rs), not the binary, specifically so it’s directly unit- testable against synthetic inputs without needing a real recorded WAV file — the same “pure logic first” split this whole chapter describes, applied to a benchmarking tool rather than a gameplay feature. This exists as the deliberate first step of a benchmark-first policy for ever touching the detection algorithms themselves: don’t change detection logic on a hunch, measure it against a reproducible dataset first.

Profiling with Tracy

cargo run --release --features trace_tracy, with the Tracy UI (https://github.com/wolfpld/tracy) already open and “Connect” clicked.

How it hangs together

trace_tracy (Cargo.toml) just forwards to bevy/trace_tracy, which already wraps every ECS system call in its own info_span!("system", name = ..) — most of what shows up in Tracy needs no manual instrumentation at all. Two things this crate adds on top:

  • main.rs’s LogPlugin is feature-gated: the everyday filter ("warn,bevy_render::camera=error") sets the default level below info, which silently drops every span (Bevy’s own and ours) before any backend — Tracy included — ever sees them (see LogPlugin::build_filter_layer, which folds filter’s own bare directives over level). A trace_tracy build swaps in a filter with no bare-level directive below info, so the configured Level::INFO default actually holds.
  • Manual spans cover the paths automatic per-system instrumentation can’t reach — anything that isn’t itself a system call. Two categories so far:
    • Off the ECS schedule entirely: the cpal capture callback (audio_input::push_chunks) runs on its own real-time thread; the only custom AssetLoader (song::loader::SongChartLoader::load) runs as a future on the AssetServer’s IO task pool. Both get a manual span for the same reason — Bevy’s per-system spans only wrap systems the schedule itself calls, so anything running elsewhere (another thread, another executor) is otherwise invisible no matter how expensive it is. A span held across an .await needs tracing::Instrument (via bevy::log::tracing::Instrument) rather than a plain .entered() guard — an EnteredSpan isn’t Send, which the loader’s returned future must be; SongChartLoader::load is a thin wrapper that instruments a load_inner for exactly this reason.
    • A hot inner loop worth breaking out of its system’s own total time: pipeline::process_audio’s per-chunk work; pitch_detect::analyze’s FFT transform and per-algorithm dispatch; build_nmf_dict (the priciest one-off, rebuilt only when the NMF dictionary goes stale); waveform::analyze_ogg_waveform/analyze_wav_waveform (a whole-file decode — also called from the off-schedule asset loader above, so it carries both reasons at once). Add spans the same way for any other code that runs off the main schedule (more asset loaders, decode threads, the asset watcher — though assets_management::watch’s debouncer thread runs only notify-debouncer-full’s own code, nothing of ours, so there’s nothing to instrument there) or burns real time inside a single system call.

Driving a Running Game from Outside

--features dev starts a Bevy Remote Protocol server — JSON-RPC over HTTP on 127.0.0.1:15702 — so a running game can be inspected, mutated, screenshotted and recorded from a shell, with no rebuild and no code added at each interesting moment. Wiring is src/dev_capture.rs.

Never shipped. BRP is unauthenticated and can read and mutate arbitrary world state. dev is a compile-time feature, so a release build doesn’t merely disable it, it doesn’t contain it.

Running

cargo run --features dev

Launching the built binary directly needs one extra thing, or every asset fails to load and you get a blank window: Bevy resolves assets/ relative to the executable (target/debug/assets), not the working directory, unless CARGO_MANIFEST_DIR is set — which cargo run does and a bare ./harmonicon does not.

BEVY_ASSET_ROOT="$PWD" ./target/debug/harmonicon

(configured_asset_plugin in src/lib.rs special-cases this for macOS debug builds only; everywhere else, use one of the two forms above.)

A helper for the examples below:

brp() { curl -s -X POST http://127.0.0.1:15702 \
        -H 'Content-Type: application/json' -d "$1"; }

Screenshots → target/screenshots/

Bevy’s own Screenshot component is Reflect and registered, so BRP can spawn one. dev_capture’s global observer writes whatever gets captured:

brp '{"jsonrpc":"2.0","id":1,"method":"world.spawn_entity","params":
     {"components":{"bevy_render::view::window::screenshot::Screenshot":
     {"Window":"Primary"}}}}'

Files are named shot_<unix_millis>.png, so repeated captures accumulate rather than overwriting. Deliberately not bevy’s save_to_disk, which takes one fixed path.

Video → target/video/

Set frames_left; one frame is captured per rendered frame until it reaches zero. Each recording gets its own numbered directory.

brp '{"jsonrpc":"2.0","id":2,"method":"world.mutate_resources","params":
     {"resource":"harmonicon::dev_capture::VideoCapture",
      "path":".frames_left","value":300}}'

The game writes numbered PNGs; encoding is left outside, since pulling a video encoder into the dependency tree for a dev tool isn’t worth it:

ffmpeg -y -framerate 30 -i target/video/0001/frame_%06d.png \
       -c:v libx264 -pix_fmt yuv420p target/video/0001.mp4

Capturing every frame stalls the render loop — each frame is a GPU readback. Expect the recorded clip to run slower than real time, and don’t use it to judge performance or timing. It shows what happened, not how fast.

Inspecting and mutating state

# What the menu actually says right now
brp '{"jsonrpc":"2.0","id":3,"method":"world.query","params":
     {"data":{"components":["bevy_ui::widget::text::Text"]},"filter":{}}}'

# Everything reachable
brp '{"jsonrpc":"2.0","id":4,"method":"world.list_components"}'
brp '{"jsonrpc":"2.0","id":5,"method":"world.list_resources"}'
brp '{"jsonrpc":"2.0","id":6,"method":"rpc.discover"}'

Type paths are the full Rust paths and they matter: UI text is bevy_ui::widget::text::Text, not bevy_text::text::Text (which exists, is registered, and matches nothing on a UI node).

world.mutate_components/world.mutate_resources change state live, and world.write_message/world.trigger_event fire messages and events without synthesising input — useful precisely where synthetic input is unreliable (see Building and Running on Android on sub-frame taps being dropped).

The catch: only reflected, registered types are visible

BRP reaches everything through AppTypeRegistry. Bevy’s own components are registered, which covers the whole UI tree, text, transforms and windows. Most of this codebase’s own types are notEditorToolbar, MicStatus, Scroll and friends are plain #[derive(Component)]/ Resource, so world.list_resources shows only the handful that derive Reflect and call register_type precisely so they can be driven from outside: VideoCapture, and NextState<AppState>/NextState<MenuPage>.

Add #[derive(Reflect)] + app.register_type::<T>() per type as the need arises, rather than blanket-deriving it.

Screens that need no prior selection are one NextState write away:

brp '{"jsonrpc":"2.0","id":7,"method":"world.mutate_resources","params":
     {"resource":"bevy_state::state::resources::NextState<harmonicon_menu::menu::routing::MenuPage>",
      "path":"","value":{"Pending":"Options"}}}'

Note the exact path: bevy_state::state::resources::NextState, not …::states::NextState. Swap MenuPage for harmonicon_app::app::AppState ("Calibration", "SongEditor2", "BendingTrainer", …) for the screens outside the menu hierarchy.

That is as far as state alone gets you. Play 2D/3D, Jam Session and Results all need a SelectedSong first, which holds a Handle<SongManifest> — not something a JSON value can express. So the other way is to click the button, which is why dev_capture registers bevy_ui_widgets::Activate:

brp '{"jsonrpc":"2.0","id":8,"method":"world.trigger_event","params":
     {"event":"bevy_ui_widgets::Activate","value":{"entity":4294966678}}}'

Activate carries its own target, so the entity goes in the payload rather than in a separate parameter. Since every click handler in this codebase is an On<Activate> on a real bevy_ui_widgets::Button (see CLAUDE.md), one trigger reaches any of them.

Finding the entity is the fiddly part. A label lives on a Text node that may sit several levels below the entity carrying Buttondialogs/button.rs wraps its content in a shell — so resolve it by walking bevy_ecs::hierarchy::ChildOf upward until you hit an entity in the Button set, rather than assuming the text’s immediate parent is the button.

One trap worth knowing: query the text nodes and the hierarchy in as few round trips as you can. Menu pages despawn and respawn their whole subtree on navigation, so an entity id read in one request can be gone by the next, which looks exactly like “this button doesn’t exist”.

A stale entity id can kill the game, not just fail. world.trigger_event on a despawned entity is harmless, but world.insert_components and world.mutate_components panic inside bevy_remote itself — the panic propagates through process_remote_requests and takes the process down:

thread 'main' panicked at bevy_remote-0.19.1/src/builtin_methods.rs:1194
Note that interacting with a despawned entity is the most common cause

Gameplay is worse than menus for this, because a scene can vanish on its own schedule: a song that reaches its end tears down every GameplayRoot entity and moves to Results with no input from you. Re-query immediately before mutating, and don’t assume an id you captured before a sleep is still alive.

What it’s already used for

Every image in docs/book/src/images/ is a real capture taken this way — nine of the fifteen by setting NextState alone, the rest (gameplay, the jam grid, the results screen, the tour overlay, the microphone dropdown) by clicking through to them. If a screen changes, re-take that one PNG under the same filename; the ![...](images/foo.png) references don’t move.

What this does not give you

  • Rendering correctness. BRP reports the string "♬ Import MIDI" whether or not the font has the glyph. Five tofu boxes shipped in every locale for months and only a screenshot caught them. Use both.
  • Timing. See the readback stall above.
  • Audio.

Android

The same server runs in the Android build (--features dev), reachable by forwarding the port:

adb forward tcp:15702 tcp:15702

The capture directories are then inside the app’s sandbox rather than target/, so pull them with adb.

Glossary

Short definitions for terms this book uses repeatedly, each pointing at the chapter that covers it in depth. Not exhaustive — see each chapter’s own prose for anything not listed here.

AppState — the top-level screen state machine (Startup, Menu, SongLoading, Playing, Results, Calibration, Credits, SongEditor2, BendingTrainer). See Application States and Modes.

AssetLoader — Bevy’s mechanism for turning raw file bytes into a typed Asset, run asynchronously off the main thread. Harmonicon has two custom ones: SongChartLoader (a chart folder → SongManifest) and ThemeJsonLoader (theme.jsonThemeJson). See Chart Format and Asset Loading and Localization and Theming.

EditorState — the Song Editor’s single resource holding its entire in-memory document: every placed note, the tempo map, meta-form fields, selection, and mode. See The Song Editor.

GameplayClock — the single authoritative “now” every scored- gameplay system reads, anchored to the music sink’s own playback position once music starts rather than tracking wall-clock time directly. See The Gameplay Clock.

GameplayMode — which of three experiences AppState::Playing currently means: Play2D, Play3D, or JamSession. See Application States and Modes.

GameplayLogic — the Bevy SystemSet grouping the clock tick, scoring, and loop-boundary systems; every clock-reading system must be ordered .after it. See The Gameplay Clock and The Plugin Architecture.

GridNote — the Song Editor’s own per-note type (distinct from gameplay’s ScheduledNote): { id, hole, tick, len, dir, pitch, expr }. See The Song Editor.

HarpChart — the parsed, typed representation of a .harpchart JSON file. See Chart Format and Asset Loading.

LessonManifest — a lesson’s authored content (lesson.json): identity, prerequisites, pass criteria, and Fluent key references (never raw display text). See The Lessons Engine.

LessonContext — the resource kept in flight for the duration of a lesson run, read by the results screen (or, for a jam-based lesson, the pause menu’s “Finish Lesson” button) to judge the run’s pass_criteria. See The Lessons Engine.

LoadedTheme — the resource holding the currently active UI theme’s resolved colors and asset handles, populated asynchronously once its ThemeJson asset load resolves. See Localization and Theming.

MenuPage — the Bevy SubStates enum scoped to AppState::Menu, one variant per menu screen. See Application States and Modes.

MidiTrackAudio / MidiTrackPlayerMidiTrackAudio (on SongManifest) is one MIDI track’s own pre-rendered AudioSource stem; MidiTrackPlayer(usize) (a component, in gameplay::state) tags the live AudioSink entity playing that stem so per-track mute can find it. See Jam Session.

MusicPlayer — the component tagging whichever entity is currently playing a song’s background music, used by pause/resume and global- volume-slider systems to find it (or, for MIDI multi-track backing, all of them at once). See Jam Session.

PitchEvent — the Message published once per analyzed audio chunk, carrying every pitch detected in it. See The Audio Input Pipeline.

PitchGate — the fresh-attack gate (wrapping scoring:: AttackGate<u8>) that keeps a single sustained note from being re-credited to multiple chart notes at the same pitch. See The Scoring System.

PitchRange — the current min/max frequency pitch detection searches, narrowed to the active harmonica’s real playable range at song start (rather than a fixed global range) to reduce false positives and, for the NMF algorithm, to know which dictionary to build. See The Audio Input Pipeline.

Plugin (composition root) — a plugin, like gameplay::plugin, whose entire job is assembling other features’ systems into one shared schedule — legitimately depends on everything it wires together, unlike an ordinary feature module. See Module Boundaries and Dependency Rules.

ScheduledNote — one chart note’s live score state during real gameplay, held in SongNotes as plain data, independent of whatever (if any) render entity currently represents it on screen. See The Scoring System.

SnapMode — the Song Editor’s Straight/Shuffle/Triplet grid subdivision setting, constraining where a note placement or drag lands. See The Song Editor.

SongManifest — the fully-loaded representation of a song: its HarpChart plus every resolved (or gracefully defaulted) sibling asset — background art, backing music or MIDI track stems, note-theme configs. See Chart Format and Asset Loading.

SongNotes — the resource holding the entire loaded chart’s worth of ScheduledNotes plus a scan-avoidance cursor; scoring’s actual data model. See The Scoring System.

AttackGate<K> — the pure, generic fresh-attack state machine PitchGate and Jam Session’s ImprovStats both wrap, keyed by whatever identity type K the caller needs (a MIDI note number for scoring). See The Scoring System.