conductor
The duel state machine, the judgement, the score, the two meters, and the campaign. It owns no geometry and no anchor: everything the player *sees* of the rules is drawn by the highway, the hud, the warlords and the venue, off the events published here. The loop is correct when n…
game.conductor — the rules, and the loop
The duel state machine, the judgement, the score, the two meters, and the campaign. It owns no geometry and no anchor: everything the player sees of the rules is drawn by the highway, the hud, the warlords and the venue, off the events published here. The loop is correct when nothing is drawing it.
The one piece of arithmetic the whole game rests on
offset = songTime - (noteTime + ST.RULES.calibrationSeconds)
Negative early, positive late, graded against ST.RULES.window:
KIWAMI ±45 ms · SHIN ±90 ms · KA ±140 ms · MISS beyond.
Measured on this engine, through input.simulateKeyDown (the same key
edges a player's fingers produce, read by input.snapshot() inside the
conductor's own tick):
| deliberate bias | KIWAMI | SHIN | KA | MISS |
|---|---|---|---|---|
| 0 ms | 20 | 0 | 0 | 2 |
| 65 ms | 0 | 19 | 0 | 1 |
| 120 ms | 0 | 1 | 13 | 7 |
| 260 ms | 0 | 0 | 0 | 14 |
ST.meanOffset() over 37 consecutive clean hits read 2.0 ms.
The loop, per frame
tick(dt, songTime) runs once per engine frame — guarded on
input.frameId(), so one judgement pass happens whatever else calls it.
- spawn every note now inside
ST.WORLD.approachSecondsof the receptor, asnote.spawn; - pull notes into the judgement horizon and retire the finished ones;
- beats and phase — one
beatper beat,phase.changeon a section boundary; - read the keys —
keys_pressed/keys_released/keysfrominput.snapshot(), mapped to lanes throughST.RULES.keys; - judge each press against the nearest unjudged note in its lane;
- holds — bank one that ran out, or one whose key came up;
- reap anything nothing could still hit;
- publish the meters, and test the three end conditions.
A press with no note under it is not punished. A rhythm game that punishes an extra tap punishes enthusiasm.
When a note is missed
ST.RULES.missAfter is 150 ms and the KA window is 140 ms — ten
milliseconds apart, less than half a frame. Input is sampled once a frame,
so a press genuinely 140 ms late is not seen for up to a frame more.
Reaping strictly at 150 ms therefore throws away legitimate KA hits and
misses the player for a note they hit. A note is retired only once no press
arriving this frame could still have been inside its window:
max(missAfter, window.ka + dt).
Where the music comes from
ST.need("game.sound").startTrack(stageId) -- the nice path
ST.clickTrack(bpm, seconds) -- the guaranteed one
The conductor asks the sound slice first, and checks that the clock
actually got bound — a startTrack that returns true without binding
ST.clock is treated as a failure, because an unbound clock means an
untimed duel. Failing that it synthesises a metronome at the stage's own
tempo, plays it from ConductorDeck under the Fx anchor, and binds the
clock to that. Either way the clock ends up reading a real playing voice.
In fallback the conductor also answers its own sfx topic with four
synthesised one-shots — a bright bell for KIWAMI, duller for SHIN and KA,
a dead thud for a miss — so a judgement is never silent while the sound
slice is still being built. Once game.sound answers, those go quiet.
Beats in, seconds out
A chart is authored in beats (game.chart) and resolved once, at duel
start, with ST.clock.beatToTime() — after the clock has been bound, so
the conversion uses the tempo of the voice that is actually playing. Before
resolving, the conductor compares ST.clock.bpm against the stage's own
BPM and refuses the duel if they differ, naming both. A chart drifting
against its track is the bug that looks fine in every static check; this is
where it is caught.
What it publishes
duel.begin · duel.end · note.spawn · note.hit · note.miss ·
hold.begin · hold.end · meter.change · phase.change · beat ·
sfx — plus three of its own: lane.press (a key edge, hit or not, for a
receptor to react to), duel.cue (a duel is wanted; the story slice's beat
goes here) and campaign.done.
sfx payloads carry name and judgement, so the sound slice can key
off either: hit_kiwami, hit_shin, hit_ka, miss, break, fall,
hold.
What it listens to
ui.command—start/retry/next/quit.story.done— a staged beat has finished; the cued duel starts.
The flow
beginCampaign() -> cueDuel(stage 1) -> [story beat] -> startDuel
^ |
| duel.end <----------+
| |
+---- advance() / retry() <---- ui.command
cueDuel gives the story slice its beat first if that slice is built,
waits for story.done, and starts the duel anyway after 30 s if that never
comes — a staged beat that never finishes must not wedge the game. With no
story slice it starts immediately.
With neither a hud nor a story slice registered, the conductor emits
screen.change itself and walks the campaign on its own two seconds after
each duel, so the whole loop is provable with nothing else built. The
moment either slice registers, it stops doing both.
Outcomes
| outcome | condition |
|---|---|
break | the rival's RESOLVE reached 0 — the duel ends early, in triumph |
hold | the track ended with COMPOSURE above 0 |
fall | COMPOSURE reached 0 |
duel.end carries outcome, won, score, maxCombo, accuracy,
judgeCounts, meanOffset, notesTotal and notesJudged.
Scoring
ST.RULES.points per judgement, times a combo multiplier that steps by
0.5 every comboBonusEvery (10) and caps at comboMultiplierMax (4.0). A
miss resets the combo and the multiplier. A hold that survives past 60% of
its length keeps the combo; anything less drops it.
Reading the loop from outside
local c = ST.need("game.conductor")
c.status() -- everything the loop believes, as one flat table
c.upcoming(1, 8) -- the resolved note list, with judgements so far
c.lastResult() -- the duel.end payload of the duel that just ended
c.isPlaying()
status() reports the clock as well — songTime, rawTime,
clockMaxErrorMs, clockResyncs — because a timing complaint is almost
always a clock complaint, and this is where you look first.
Scoped to this part · feeds back into the world's score.