diff --git a/Models/ScoreboardState.cs b/Models/ScoreboardState.cs
new file mode 100644
index 0000000..85245a1
--- /dev/null
+++ b/Models/ScoreboardState.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ytLive.Models;
+
+/// The session's scoreboard tallies — the facts a creator puts on screen to prove the
+/// channel is moving.
+///
+/// Scope is the RUNNING SESSION (creator ruling 2026-10-30): the counts start at zero when a
+/// session starts and die with it. No persistence, nothing to reconcile, and every number is true
+/// for exactly what this session saw — a stored "today" that survives a Windows restart would be
+/// right only for the hours the app happened to be running, which is a lie the creator cannot
+/// check.
+///
+/// The two polled tiles (subscribers, watch hours) are NOT here: they are channel state, not
+/// session events, and live on the polled side of the scoreboard.
+public sealed class ScoreboardState
+{
+ /// Super Chat + Super Sticker money, per currency. Adding across currencies would be
+ /// the one arithmetic nobody forgives, so totals are keyed and only the dominant one is shown.
+ private readonly Dictionary _moneyByCurrency = new(StringComparer.OrdinalIgnoreCase);
+
+ public string? LastMemberName { get; private set; }
+ public string? LastSuperChatAuthor { get; private set; }
+ public string? LastSuperChatDisplay { get; private set; }
+ public double LastSuperChatAmount { get; private set; }
+ public string? LastSuperChatCurrency { get; private set; }
+ public string? LastGifterName { get; private set; }
+ public int LastGiftCount { get; private set; }
+
+ /// Members gained this session: paid memberships plus gifted ones (counted once, from
+ /// the GIFT PURCHASE — see ).
+ public int NewMembers { get; private set; }
+
+ public int MilestoneCount { get; private set; }
+
+ public IReadOnlyDictionary MoneyByCurrency => _moneyByCurrency;
+
+ /// The session's dominant currency (most money) and its total, or ("", 0) when nothing
+ /// has come in. Chosen by VALUE so a single EUR tip beside ten USD cents cannot flip the display
+ /// to the smaller currency.
+ public (string Currency, double Total) SessionTotal =>
+ _moneyByCurrency.Count == 0
+ ? (string.Empty, 0)
+ : _moneyByCurrency.OrderByDescending(pair => pair.Value).First() is var top
+ ? (top.Key, top.Value)
+ : (string.Empty, 0);
+
+ public bool HasMixedCurrencies => _moneyByCurrency.Count > 1;
+
+ /// Folds one chat message into the tallies. Every event kind the poll decodes is
+ /// handled; plain chat is ignored.
+ ///
+ /// giftMembershipReceivedEvent deliberately does nothing. It is the RECIPIENT's view
+ /// of a purchase already counted by membershipGiftingEvent, so counting it would double both the
+ /// members and the money (creator ruling 2026-10-30: "come on").
+ public void Apply(ChatMessage message)
+ {
+ switch (message.Kind)
+ {
+ case ChatEventKind.NewMember:
+ NewMembers++;
+ LastMemberName = message.AuthorName;
+ break;
+
+ case ChatEventKind.MemberGift:
+ // The purchase: one event carrying how many memberships were bought.
+ var count = Math.Max(1, message.GiftNewMembersCount);
+ NewMembers += count;
+ LastGiftCount = count;
+ LastGifterName = message.AuthorName;
+ break;
+
+ case ChatEventKind.GiftReceived:
+ break; // recipient side of the event above — never counted
+
+ case ChatEventKind.MemberMilestone:
+ MilestoneCount++;
+ break;
+
+ case ChatEventKind.SuperChat:
+ case ChatEventKind.SuperSticker:
+ LastSuperChatAuthor = message.AuthorName;
+ LastSuperChatAmount = message.SuperChatAmount;
+ LastSuperChatCurrency = string.IsNullOrWhiteSpace(message.SuperChatCurrency)
+ ? "USD"
+ : message.SuperChatCurrency!;
+ LastSuperChatDisplay = message.SuperChatDisplayString
+ ?? FormatMoney(LastSuperChatAmount, LastSuperChatCurrency);
+ if (message.SuperChatAmount > 0)
+ {
+ _moneyByCurrency.TryGetValue(LastSuperChatCurrency!, out var running);
+ _moneyByCurrency[LastSuperChatCurrency!] = running + message.SuperChatAmount;
+ }
+ break;
+ }
+ }
+
+ /// A new session starts from zero.
+ public void Reset() => CopyFrom(new ScoreboardState());
+
+ private void CopyFrom(ScoreboardState other)
+ {
+ _moneyByCurrency.Clear();
+ foreach (var pair in other._moneyByCurrency) _moneyByCurrency[pair.Key] = pair.Value;
+ LastMemberName = other.LastMemberName;
+ LastSuperChatAuthor = other.LastSuperChatAuthor;
+ LastSuperChatDisplay = other.LastSuperChatDisplay;
+ LastSuperChatAmount = other.LastSuperChatAmount;
+ LastSuperChatCurrency = other.LastSuperChatCurrency;
+ LastGifterName = other.LastGifterName;
+ LastGiftCount = other.LastGiftCount;
+ NewMembers = other.NewMembers;
+ MilestoneCount = other.MilestoneCount;
+ }
+
+ private static string FormatMoney(double amount, string? currency) =>
+ string.IsNullOrEmpty(currency) ? amount.ToString("0.00") : $"{currency} {amount:0.00}";
+}
diff --git a/ytLive.Tests/ScoreboardStateTests.cs b/ytLive.Tests/ScoreboardStateTests.cs
new file mode 100644
index 0000000..eb2aa1a
--- /dev/null
+++ b/ytLive.Tests/ScoreboardStateTests.cs
@@ -0,0 +1,68 @@
+using Xunit;
+using ytLive.Models;
+
+namespace ytLive.Tests;
+
+/// Session-scoped scoreboard tallies. The rules that matter: money is never added across
+/// currencies, and a gifted membership is counted ONCE (from the purchase), never again from the
+/// recipient's event — that double-count is the easy mistake, and the creator called it out.
+public class ScoreboardStateTests
+{
+ private static ChatMessage Message(ChatEventKind kind, string author = "viewer", double amount = 0,
+ string? currency = "USD", int giftCount = 0, string? display = null) => new()
+ {
+ Kind = kind, AuthorName = author, SuperChatAmount = amount,
+ SuperChatCurrency = currency, GiftNewMembersCount = giftCount, SuperChatDisplayString = display,
+ };
+
+ [Fact]
+ public void Tallies_follow_the_six_events_and_never_double_count_a_gift()
+ {
+ var state = new ScoreboardState();
+
+ state.Apply(Message(ChatEventKind.NewMember, "Paid"));
+ Assert.Equal(1, state.NewMembers);
+ Assert.Equal("Paid", state.LastMemberName);
+
+ // A gift purchase of 5 memberships counts 5...
+ state.Apply(Message(ChatEventKind.MemberGift, "Gifter", giftCount: 5));
+ Assert.Equal(6, state.NewMembers);
+ Assert.Equal(5, state.LastGiftCount);
+ Assert.Equal("Gifter", state.LastGifterName);
+
+ // ...and the recipients' own events add NOTHING (they are the same memberships).
+ for (var i = 0; i < 5; i++) state.Apply(Message(ChatEventKind.GiftReceived, $"Recipient{i}", giftCount: 1));
+ Assert.Equal(6, state.NewMembers);
+ Assert.Equal(5, state.LastGiftCount);
+ Assert.Equal("Gifter", state.LastGifterName);
+
+ state.Apply(Message(ChatEventKind.MemberMilestone, "Longtimer"));
+ Assert.Equal(1, state.MilestoneCount);
+
+ // Super Chat and Super Sticker both carry money; the last one drives the "latest" tile.
+ state.Apply(Message(ChatEventKind.SuperChat, "Tipper", 10, "USD", display: "$10.00"));
+ state.Apply(Message(ChatEventKind.SuperSticker, "Sticker", 5, "USD"));
+ Assert.Equal(15, state.SessionTotal.Total, 3);
+ Assert.Equal("USD", state.SessionTotal.Currency);
+ Assert.Equal("Sticker", state.LastSuperChatAuthor);
+ Assert.False(state.HasMixedCurrencies);
+
+ // A second currency is tracked separately, never summed into the first.
+ state.Apply(Message(ChatEventKind.SuperChat, "Euro", 3, "EUR"));
+ Assert.Equal(15, state.MoneyByCurrency["USD"], 3);
+ Assert.Equal(3, state.MoneyByCurrency["EUR"], 3);
+ Assert.True(state.HasMixedCurrencies);
+ Assert.Equal("USD", state.SessionTotal.Currency); // dominant by value, not by recency
+
+ // Plain chat is ignored entirely.
+ state.Apply(Message(ChatEventKind.None, "Chatter"));
+ Assert.Equal(6, state.NewMembers);
+ Assert.Equal(1, state.MilestoneCount);
+
+ // A new session starts clean.
+ state.Reset();
+ Assert.Equal(0, state.NewMembers);
+ Assert.Equal(0, state.SessionTotal.Total);
+ Assert.Null(state.LastMemberName);
+ }
+}