core
Everything every slice of the game agrees on. The screen shape, the rules, the three duels, the **beat clock**, the event bus, the shared state, and the scene anchors.
game.core — the shared contract of SENGOKU TRANCE
Everything every slice of the game agrees on. The screen shape, the rules, the three duels, the beat clock, the event bus, the shared state, and the scene anchors.
local ST = require("game.core")
A bare dotted identity in the world root. @world::game.core does not
resolve.
Exports
ST.VIEW— the screen is 1080 x 1440, 3:4 portrait, andVIEW.banddivides it into stage / highway / hud strips. Lay out inside your band.ST.WORLD— the metres: lane positions, the receptor plane, where the stage is, where each duellist stands, and the fixed gameplay camera.ST.RULES— judgement windows, damage, scoring, keys, and the measured latency constants.ST.PALETTE/ST.LANE_COLOR— the blacklight-ukiyo-e colours. Draw from these so the three duels read as one game.ST.STAGES/ST.stage(id)— the three duels, each with a fixed BPM and a psytrance section map.ST.clock— the beat clock.songTime(),beat(),beatPhase(),beatToTime(beat).ST.bus—on(topic, fn) -> off,emit(topic, payload),clear().ST.TOPICSlists the contract topics.ST.state— the live duel numbers. The conductor writes; everyone reads.ST.anchor(name)/ST.clearAnchor(name)— where a slice hangs its entities. No slice edits the scene file.ST.provide(id, api)/ST.need(id)— the slice registry.ST.lerp,ST.clamp,ST.color,ST.accuracy,ST.meanOffset,ST.phaseAt,ST.resetDuel.
The beat clock — measured, not assumed
The engine does report playback position: audio.voice(id).positionSeconds.
Measured on this build, over 90 consecutive frames:
| quantity | measured |
|---|---|
| distinct positions in 90 frames | 90 — it advances every frame |
| quantum | 11.6 ms (steps are exact multiples of 11604 us) |
| frame time | 21.7 ms — the audio clock is finer than the frame |
lag behind a wall clock started at :play() | 20-23 ms, steady |
| drift over the window | none measurable |
So the clock is driven by the engine, not free-running. But an 11.6 ms
quantum read on a 21.7 ms frame makes notes judder, so ST.clock runs a
hybrid: it free-runs on the wall clock between raw samples and re-locks to
every fresh one (nudging 18% a frame, snapping past 80 ms of error). Read
songTime() and never audio.voice() directly.
ST.clock.tick(now) is called once a frame by the scene entrypoint.
A slice never calls it.
Latency, and what we do about it
Input is sampled once a frame, so timing resolution can never beat ±11 ms.
The windows are sized for that: KIWAMI ±45 ms, SHIN ±90 ms, KA ±140 ms.
The residual output latency is measured from the player by the
calibration step, which writes ST.RULES.calibrationSeconds; the conductor
keeps a running mean offset (ST.meanOffset()) so a systematic bias is
visible in data rather than guessed at.
Notes
- The three tracks are generated to the BPM in
ST.STAGES, never the other way round. A chart is authored in beats;ST.clock.beatToTimeresolves it. That is why a chart can never disagree with its track. ST.need(id)returns nil for a slice that is not built yet. Always handle nil — that is what lets the slices build in parallel.
Scoped to this part · feeds back into the world's score.