# 4. Note Highway Input and Judgement Part of [[MIDI Rhythm Game]]. Prev: [[3. Note Charts and MIDI File Import]]. Next: [[5. Chart-Driven MIDI Out to the Moogs]]. ## 4.1 Rules of the judge 1. All times are **song seconds** from the conductor (tempo-map safe). 2. A press is matched to the **closest unjudged note in that lane** inside the widest window. Presses with no note nearby are ignored (no penalty; Rogers would call a penalty for a stray tap "punishing the player for exploring"). 3. Any note whose time is more than the widest window in the past is a **Miss** (the miss sweep). 4. Holds: hitting the head starts the hold; releasing early breaks combo; holding to the tail gives a bonus. ## 4.2 `JudgementWindows.cs` ```csharp using UnityEngine; public enum Judgement { Perfect, Great, Good, Miss } /// <summary>Half-widths in milliseconds (a 45 ms Perfect means -45..+45). Type Object per difficulty. NOT compiled.</summary> [CreateAssetMenu(menuName = "Rhythm/Judgement Windows", fileName = "JudgementWindows")] public sealed class JudgementWindows : ScriptableObject { [Min(1)] public float perfectMs = 45f; [Min(1)] public float greatMs = 90f; [Min(1)] public float goodMs = 135f; public int perfectScore = 300, greatScore = 200, goodScore = 100, holdTailBonus = 50; public double WidestSec => goodMs / 1000.0; public Judgement Classify(double errorSec) { double ms = System.Math.Abs(errorSec * 1000.0); if (ms <= perfectMs) return Judgement.Perfect; if (ms <= greatMs) return Judgement.Great; if (ms <= goodMs) return Judgement.Good; return Judgement.Miss; } public int ScoreFor(Judgement j) => j switch { Judgement.Perfect => perfectScore, Judgement.Great => greatScore, Judgement.Good => goodScore, _ => 0 }; } ``` Make an **Easy** asset with wider windows rather than slowing the song. That is difficulty as data ([[Game Balance and Dynamic Difficulty]]). ## 4.3 `RhythmJudge.cs` ```csharp using System; using System.Collections.Generic; using UnityEngine; public readonly struct JudgeResult { public readonly int noteId, lane; public readonly Judgement judgement; public readonly double errorMs; // negative = early, positive = late public readonly bool isHoldTail; public JudgeResult(int noteId, int lane, Judgement j, double errorMs, bool isHoldTail) { this.noteId = noteId; this.lane = lane; judgement = j; this.errorMs = errorMs; this.isHoldTail = isHoldTail; } } /// <summary>Matches timestamped presses to chart notes, sweeps misses, tracks score and combo. NOT compiled.</summary> [DefaultExecutionOrder(-50)] // after the conductor (-100), before views public sealed class RhythmJudge : MonoBehaviour { [SerializeField] RhythmConductor conductor; [SerializeField] JudgementWindows windows; public sealed class PlayableNote { public int id, lane; public double timeSec, endSec; // endSec == timeSec for taps public bool judged, holding, tailDone; public bool IsHold => endSec > timeSec; } public event Action<JudgeResult> Judged; public event Action<int> ComboChanged; public int Score { get; private set; } public int Combo { get; private set; } public int MaxCombo { get; private set; } public readonly int[] Counts = new int[4]; // indexed by (int)Judgement public IReadOnlyList<PlayableNote> AllNotes => _all; readonly List<PlayableNote> _all = new(); List<PlayableNote>[] _lanes = Array.Empty<List<PlayableNote>>(); int[] _sweep = Array.Empty<int>(); // first possibly-unjudged index per lane public void Load(NoteChart chart) { _all.Clear(); _lanes = new List<PlayableNote>[chart.laneCount]; _sweep = new int[chart.laneCount]; for (int i = 0; i < _lanes.Length; i++) _lanes[i] = new List<PlayableNote>(); int id = 0; foreach (var track in chart.TracksWithRole(ChartTrackRole.Playable)) foreach (var n in track.notes) { var pn = new PlayableNote { id = id++, lane = Mathf.Clamp(n.lane, 0, chart.laneCount - 1), timeSec = chart.BeatToSeconds(n.beat), endSec = chart.BeatToSeconds(n.beat + n.lengthBeats) }; _all.Add(pn); _lanes[pn.lane].Add(pn); } foreach (var l in _lanes) l.Sort((a, b) => a.timeSec.CompareTo(b.timeSec)); _all.Sort((a, b) => a.timeSec.CompareTo(b.timeSec)); Score = Combo = MaxCombo = 0; Array.Clear(Counts, 0, Counts.Length); } /// <summary>Called by RhythmInput with the song time at which the press physically happened.</summary> public void Press(int lane, double songTime) { if (lane < 0 || lane >= _lanes.Length) return; var list = _lanes[lane]; double widest = windows.WidestSec; PlayableNote best = null; double bestAbs = double.MaxValue; for (int i = _sweep[lane]; i < list.Count; i++) { var n = list[i]; if (n.timeSec - songTime > widest) break; // later notes are even further away if (n.judged) continue; double abs = Math.Abs(n.timeSec - songTime); if (abs <= widest && abs < bestAbs) { best = n; bestAbs = abs; } } if (best == null) return; // stray tap: ignored on purpose double error = songTime - best.timeSec; var j = windows.Classify(error); best.judged = true; if (best.IsHold && j != Judgement.Miss) best.holding = true; Apply(new JudgeResult(best.id, lane, j, error * 1000.0, false)); } public void Release(int lane, double songTime) { if (lane < 0 || lane >= _lanes.Length) return; foreach (var n in _lanes[lane]) { if (!n.holding || n.tailDone) continue; n.holding = false; n.tailDone = true; double error = songTime - n.endSec; // Releasing within the widest window of the tail (or after it) counts as a clean hold. var j = error >= -windows.WidestSec ? Judgement.Perfect : Judgement.Miss; Apply(new JudgeResult(n.id, lane, j, error * 1000.0, true)); } } void Update() { if (conductor == null || !conductor.IsRunning) return; double now = conductor.SongTime; double widest = windows.WidestSec; for (int lane = 0; lane < _lanes.Length; lane++) { var list = _lanes[lane]; // Miss sweep: anything older than the widest window that nobody hit. while (_sweep[lane] < list.Count) { var n = list[_sweep[lane]]; if (n.judged) { _sweep[lane]++; continue; } if (now - n.timeSec <= widest) break; n.judged = true; Apply(new JudgeResult(n.id, lane, Judgement.Miss, (now - n.timeSec) * 1000.0, false)); _sweep[lane]++; } // Auto-complete holds the player kept down past the tail. foreach (var n in list) { if (n.holding && !n.tailDone && now >= n.endSec) { n.holding = false; n.tailDone = true; Apply(new JudgeResult(n.id, lane, Judgement.Perfect, 0, true)); } } } } void Apply(in JudgeResult r) { if (r.isHoldTail) { if (r.judgement == Judgement.Miss) SetCombo(0); else Score += windows.holdTailBonus; } else { Counts[(int)r.judgement]++; Score += windows.ScoreFor(r.judgement); SetCombo(r.judgement == Judgement.Miss ? 0 : Combo + 1); } Judged?.Invoke(r); } void SetCombo(int value) { Combo = value; if (Combo > MaxCombo) MaxCombo = Combo; ComboChanged?.Invoke(Combo); } } ``` **Why the hold auto-complete loop scans the whole lane:** clarity over speed. Charts have a few hundred notes per lane at most. If profiling says otherwise ([[Profiling and Optimization]]), keep a small list of active holds instead. ## 4.4 `RhythmInput.cs`: keyboard, gamepad and MIDI pads The key idea: use the **event timestamp**, not "now". `InputAction.CallbackContext.time` is on the `realtimeSinceStartup` timeline and is normally the time the input event was generated, not when your callback ran (Input System API docs). The conductor converts that to song time. ```csharp using UnityEngine; using UnityEngine.InputSystem; /// <summary> /// Lane input from Input System actions (keys/gamepad, timestamped) and from Minis MIDI notes /// via SynthMidiInput (stamped at callback time). NOT compiled. /// </summary> public sealed class RhythmInput : MonoBehaviour { [SerializeField] RhythmConductor conductor; [SerializeField] RhythmJudge judge; [Header("Input System (Button actions, one per lane: e.g. D F J K)")] [SerializeField] InputActionReference[] laneActions; [Header("MIDI pads / keyboard via SynthMidiInput (Eurorack and Unity)")] [SerializeField] bool useMidi = true; [Tooltip("0-based Minis channel to listen on; -1 = any.")] [SerializeField] int midiChannel0 = -1; [Tooltip("MIDI note for lane 0; lane n = base + n.")] [SerializeField] int midiLaneBaseNote = 48; [Header("Calibration")] [Tooltip("Seconds added to every input time (from LatencyCalibrator). Negative if inputs arrive late.")] [SerializeField] double inputOffsetSec = 0.0; public double InputOffsetSec { get => inputOffsetSec; set => inputOffsetSec = value; } System.Action<InputAction.CallbackContext>[] _pressed, _released; void OnEnable() { int n = laneActions?.Length ?? 0; _pressed = new System.Action<InputAction.CallbackContext>[n]; _released = new System.Action<InputAction.CallbackContext>[n]; for (int i = 0; i < n; i++) { int lane = i; // capture a copy for the lambda _pressed[i] = ctx => judge.Press(lane, ToSong(ctx.time)); _released[i] = ctx => judge.Release(lane, ToSong(ctx.time)); var a = laneActions[i].action; a.performed += _pressed[i]; a.canceled += _released[i]; a.Enable(); } if (useMidi) { SynthMidiInput.NoteOn += OnMidiNoteOn; SynthMidiInput.NoteOff += OnMidiNoteOff; } } void OnDisable() { for (int i = 0; i < (laneActions?.Length ?? 0); i++) { var a = laneActions[i].action; a.performed -= _pressed[i]; a.canceled -= _released[i]; } SynthMidiInput.NoteOn -= OnMidiNoteOn; SynthMidiInput.NoteOff -= OnMidiNoteOff; } double ToSong(double realtime) => conductor.SongTimeAtRealtime(realtime) + inputOffsetSec; void OnMidiNoteOn(int channel0, int note, float velocity01) { if (midiChannel0 >= 0 && channel0 != midiChannel0) return; // Minis callbacks do not hand us an event timestamp in SynthMidiInput, so this is "now" // (the Input System update that processed the message). Expect up to ~1 frame of jitter. judge.Press(note - midiLaneBaseNote, ToSong(Time.realtimeSinceStartupAsDouble)); } void OnMidiNoteOff(int channel0, int note) { if (midiChannel0 >= 0 && channel0 != midiChannel0) return; judge.Release(note - midiLaneBaseNote, ToSong(Time.realtimeSinceStartupAsDouble)); } } ``` > [!tip] Getting real timestamps for MIDI too > Minis turns each MIDI channel into an Input System device with note controls. If you can bind those controls directly in an action (open **Window > Analysis > Input Debugger** while pressing a pad to read the device's control paths), the action's `ctx.time` carries the Input System event timestamp, and MIDI becomes as precise as the keyboard. I did not verify Minis' control path names; read them from the Input Debugger rather than guessing. **Gameplay safety:** the lanes are `Button` actions with default interactions, so `performed` fires on press and `canceled` on release. Do not add a Hold interaction; it delays `performed`. ## 4.5 The highway Positions come from **time to hit**, not from a velocity integrated per frame, so a frame hitch never desyncs a note. ``` t = (note.timeSec - songTime) / approachSec // 1 at spawn, 0 at the hit line, negative after position = Vector3.LerpUnclamped(hitLine, spawnPoint, t) + laneOffset ``` ### `NoteView.cs` ```csharp using UnityEngine; /// <summary>A pooled visual for one note. The tail child scales for holds. NOT compiled.</summary> public sealed class NoteView : MonoBehaviour { [SerializeField] Transform tail; // child quad/cube along +Z, pivot at its start [SerializeField] Renderer body; public RhythmJudge.PlayableNote Note { get; private set; } static readonly int ColorId = Shader.PropertyToID("_BaseColor"); // URP Lit/Unlit base colour MaterialPropertyBlock _mpb; public void Bind(RhythmJudge.PlayableNote note, Color laneColor, float tailLengthWorld) { Note = note; _mpb ??= new MaterialPropertyBlock(); _mpb.SetColor(ColorId, laneColor); body.SetPropertyBlock(_mpb); if (tail != null) { tail.gameObject.SetActive(note.IsHold); tail.localScale = new Vector3(1f, 1f, Mathf.Max(0.01f, tailLengthWorld)); } } } ``` `MaterialPropertyBlock` keeps one shared material for every note; note that a property block on a renderer breaks SRP Batcher compatibility for that renderer, which is fine at this count (see [[Compute Shaders and GPU Instancing]] for thousands of instances). ### `NoteHighway.cs` ```csharp using System.Collections.Generic; using UnityEngine; using UnityEngine.Pool; /// <summary>Spawns pooled NoteViews ahead of time and positions them from song time. NOT compiled.</summary> [DefaultExecutionOrder(0)] public sealed class NoteHighway : MonoBehaviour { [SerializeField] RhythmConductor conductor; [SerializeField] RhythmJudge judge; [SerializeField] NoteView notePrefab; [SerializeField] Transform hitLine; // where notes should be when they are due [SerializeField] Transform spawnPoint; // where they appear approachSec earlier [SerializeField] float laneSpacing = 1.2f; [Tooltip("Seconds a note is visible before it is due. A player readability setting.")] [SerializeField] float approachSec = 1.5f; [SerializeField] Color[] laneColors = { Color.red, Color.yellow, Color.green, Color.cyan }; ObjectPool<NoteView> _pool; readonly Dictionary<int, NoteView> _active = new(); readonly List<int> _toRelease = new(); int _nextToSpawn; int _laneCount = 4; public float ApproachSec { get => approachSec; set => approachSec = Mathf.Clamp(value, 0.4f, 4f); } void Awake() { _pool = new ObjectPool<NoteView>( createFunc: () => Instantiate(notePrefab, transform), actionOnGet: v => v.gameObject.SetActive(true), actionOnRelease: v => v.gameObject.SetActive(false), actionOnDestroy: v => Destroy(v.gameObject), collectionCheck: false, defaultCapacity: 64, maxSize: 512); } void OnEnable() { if (judge != null) judge.Judged += OnJudged; } void OnDisable() { if (judge != null) judge.Judged -= OnJudged; } public void Load(NoteChart chart) { foreach (var v in _active.Values) _pool.Release(v); _active.Clear(); _nextToSpawn = 0; _laneCount = chart.laneCount; } void OnJudged(JudgeResult r) { // Hits disappear; misses keep scrolling past the line (feedback), then get culled below. if (r.isHoldTail || r.judgement == Judgement.Miss) return; if (_active.TryGetValue(r.noteId, out var v) && !v.Note.IsHold) { _pool.Release(v); _active.Remove(r.noteId); } } void Update() { if (conductor == null || !conductor.IsRunning) return; double now = conductor.SongTime; var notes = judge.AllNotes; // Spawn everything that is now within the approach window. while (_nextToSpawn < notes.Count && notes[_nextToSpawn].timeSec - now <= approachSec) { var n = notes[_nextToSpawn++]; var v = _pool.Get(); float tailWorld = (float)((n.endSec - n.timeSec) / approachSec) * Vector3.Distance(hitLine.position, spawnPoint.position); v.Bind(n, laneColors[n.lane % laneColors.Length], tailWorld); _active[n.id] = v; } // Position and cull. Vector3 right = hitLine.right * laneSpacing; float centre = (_laneCount - 1) * 0.5f; _toRelease.Clear(); foreach (var kv in _active) { var v = kv.Value; var n = v.Note; float t = (float)((n.timeSec - now) / approachSec); Vector3 p = Vector3.LerpUnclamped(hitLine.position, spawnPoint.position, t) + right * (n.lane - centre); v.transform.SetPositionAndRotation(p, hitLine.rotation); bool pastTail = now > n.endSec + 0.5; if (pastTail) _toRelease.Add(kv.Key); } foreach (int id in _toRelease) { _pool.Release(_active[id]); _active.Remove(id); } } } ``` ## 4.6 HUD and results ```csharp using UnityEngine; using UnityEngine.UIElements; /// <summary>UI Toolkit HUD: score, combo, last judgement with timing error. NOT compiled.</summary> public sealed class RhythmHud : MonoBehaviour { [SerializeField] UIDocument document; [SerializeField] RhythmJudge judge; Label _score, _combo, _judgement; void OnEnable() { var root = document.rootVisualElement; _score = root.Q<Label>("score"); _combo = root.Q<Label>("combo"); _judgement = root.Q<Label>("judgement"); judge.Judged += OnJudged; judge.ComboChanged += OnCombo; } void OnDisable() { judge.Judged -= OnJudged; judge.ComboChanged -= OnCombo; } void OnJudged(JudgeResult r) { _score.text = judge.Score.ToString("N0"); if (r.isHoldTail) return; string side = r.judgement == Judgement.Miss ? "" : (r.errorMs < 0 ? quot; early {-r.errorMs:0} ms" : quot; late {r.errorMs:0} ms"); _judgement.text = r.judgement + side; } void OnCombo(int combo) => _combo.text = combo > 1 ? quot;{combo} combo" : ""; } ``` Build the UXML with three `Label`s named `score`, `combo`, `judgement` (see [[UI Toolkit and Diegetic UI]]). Showing **early/late in ms** is a deliberate teaching choice: it is the single most useful feedback for players and for your calibration. On `conductor.SongFinished`, submit results using the existing save note's APIs ([[High Scores, Saves and Player Stats]]): ```csharp // In your results screen controller (NOT compiled): int rank = HighScoreTable.Submit(quot;{chart.songTitle}/{chart.difficulty}", playerName, judge.Score); PlayerStats.Instance?.SetMax(StatKeys.BestCombo, judge.MaxCombo); ``` ## 4.7 Exercise Log every `JudgeResult.errorMs` for a song into the CSV with `PlaytestTelemetry.Log("judge", Vector3.zero, result.judgement.ToString(), (float)result.errorMs)` from [[Playtest Telemetry and Heatmaps]] (namespace `Playtest.Telemetry`; use the note index as the label if you prefer). Plot error vs note index. A section where everyone is late is a chart or audio-sync bug, not a skill problem. ## Sources - `InputAction.CallbackContext.time`: https://docs.unity3d.com/Packages/[email protected]/api/UnityEngine.InputSystem.InputAction.CallbackContext.html - Input System on 6000.5 (1.20.1): https://docs.unity3d.com/6000.5/Documentation/Manual/com.unity.inputsystem.html - `ObjectPool<T>`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Pool.ObjectPool_1.html - `MaterialPropertyBlock`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/MaterialPropertyBlock.html