diff --git a/Models/ScoreboardRecorder.cs b/Models/ScoreboardRecorder.cs
new file mode 100644
index 0000000..aa24b56
--- /dev/null
+++ b/Models/ScoreboardRecorder.cs
@@ -0,0 +1,51 @@
+namespace ytLive.Models;
+
+/// Owns the session's and the one lifecycle rule that
+/// matters: a new session starts from zero.
+///
+/// Kept separate from the view-model so the rule is testable without a window, and so the
+/// scoreboard layer has exactly one place that decides when counting begins.
+public sealed class ScoreboardRecorder
+{
+ public ScoreboardState State { get; private set; } = new();
+
+ /// Called when a session starts (go-live, test, or a local recording). Every start
+ /// clears the tallies — "session" is the only scope the numbers promise.
+ public void StartSession()
+ {
+ State = new ScoreboardState();
+ IsRunning = true;
+ RaiseChanged();
+ }
+
+ /// Folds a chat event in. Outside a session nothing is counted: a stale tally left over
+ /// from the last stream would be the "today" lie this design exists to avoid.
+ public void Record(ChatMessage message)
+ {
+ if (!IsRunning) return;
+ var before = Fingerprint(State);
+ State.Apply(message);
+ if (Fingerprint(State) != before) RaiseChanged();
+ }
+
+ /// True while the tallies are live.
+ public bool IsRunning { get; private set; }
+
+ /// Marks the session finished. The tallies stay visible (the End screen may show
+ /// them) but stop moving.
+ public void EndSession()
+ {
+ IsRunning = false;
+ RaiseChanged();
+ }
+
+ public event System.Action? Changed;
+
+ private void RaiseChanged() => Changed?.Invoke();
+
+ /// Cheap change detector: only re-render the strip when a displayed value moved.
+ private static string Fingerprint(ScoreboardState state) =>
+ $"{state.NewMembers}|{state.MilestoneCount}|{state.LastMemberName}|{state.LastGifterName}|" +
+ $"{state.LastGiftCount}|{state.LastSuperChatAuthor}|{state.LastSuperChatDisplay}|" +
+ $"{state.SessionTotal.Currency}|{state.SessionTotal.Total:0.######}";
+}
diff --git a/ytLive.Tests/ScoreboardRecorderTests.cs b/ytLive.Tests/ScoreboardRecorderTests.cs
new file mode 100644
index 0000000..9ccc16d
--- /dev/null
+++ b/ytLive.Tests/ScoreboardRecorderTests.cs
@@ -0,0 +1,42 @@
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// The session is the only scope the scoreboard promises, so the rules are: a start clears
+/// everything, events outside a session are ignored, and an ended session keeps its numbers but
+/// stops moving.
+public class ScoreboardRecorderTests
+{
+ private static ChatMessage Member(string name) => new() { Kind = ChatEventKind.NewMember, AuthorName = name };
+
+ [Fact]
+ public void Starting_a_session_clears_the_tallies_and_only_a_live_session_counts()
+ {
+ var recorder = new ScoreboardRecorder();
+ var changes = 0;
+ recorder.Changed += () => changes++;
+
+ // Outside a session: nothing is counted (no stale carry-over from the last stream).
+ recorder.Record(Member("Ignored"));
+ Assert.Equal(0, recorder.State.NewMembers);
+
+ recorder.StartSession();
+ Assert.True(recorder.IsRunning);
+ recorder.Record(Member("First"));
+ recorder.Record(Member("Second"));
+ Assert.Equal(2, recorder.State.NewMembers);
+ Assert.True(changes >= 2, "each visible change must raise Changed so the strip re-renders");
+
+ // End freezes: numbers stay, movement stops.
+ recorder.EndSession();
+ Assert.False(recorder.IsRunning);
+ recorder.Record(Member("AfterEnd"));
+ Assert.Equal(2, recorder.State.NewMembers);
+
+ // The next session starts from zero.
+ recorder.StartSession();
+ Assert.Equal(0, recorder.State.NewMembers);
+ Assert.Null(recorder.State.LastMemberName);
+ }
+}