# 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