diff --git a/Models/ScoreboardTiles.cs b/Models/ScoreboardTiles.cs
new file mode 100644
index 0000000..9b1728f
--- /dev/null
+++ b/Models/ScoreboardTiles.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace ytLive.Models;
+
+/// Which of the eight facts the creator put on screen, and how each renders.
+///
+/// Every tile is optional because the strip is an ego wall — the creator chooses the
+/// billboard. Goal tiles carry their targets here so the rendering stays a pure function of the
+/// state plus this selection.
+public sealed class ScoreboardTiles
+{
+ /// Nothing enabled — the layer draws nothing.
+ public static ScoreboardTiles None => new();
+
+ /// The selection a new layer starts with: the six session facts, no channel polls
+ /// (those need the Analytics scope).
+ public static ScoreboardTiles Default => new()
+ {
+ SuperChats = true, NewMembers = true, Milestones = true,
+ LastMember = true, LastSuperChat = true, LastGift = true,
+ };
+
+ public bool Subscribers { get; set; }
+ public bool WatchHours { get; set; }
+ public bool SuperChats { get; set; }
+ public bool NewMembers { get; set; }
+ public bool Milestones { get; set; }
+ public bool LastMember { get; set; }
+ public bool LastSuperChat { get; set; }
+ public bool LastGift { get; set; }
+
+ /// Goal targets for the two polled tiles; 0 means "no goal, show the raw number".
+ public int SubscriberGoal { get; set; }
+ public int WatchHourGoal { get; set; }
+
+ /// Channel state fed by the polled side (subscribers, watch hours). Kept here so the
+ /// strip is built from ONE source of truth: tallies + channel values + this selection.
+ public int SubscriberCount { get; set; }
+ public int WatchHoursValue { get; set; }
+
+ /// Cache key: the selection alone. Values are folded in by the presenter's fingerprint,
+ /// so this only needs to change when the ENABLED SET changes.
+ public string CacheKey =>
+ $"{Subscribers}{WatchHours}{SuperChats}{NewMembers}{Milestones}{LastMember}{LastSuperChat}{LastGift}" +
+ $"{SubscriberGoal}{WatchHourGoal}";
+
+ /// The strip's text, in reading order. Empty strings are dropped by the renderer.
+ public IReadOnlyList Build(ScoreboardState state)
+ {
+ var tiles = new List(8);
+
+ if (Subscribers)
+ tiles.Add(SubscriberGoal > 0
+ ? $"Subs {SubscriberCount:N0}/{SubscriberGoal:N0}"
+ : $"Subs {SubscriberCount:N0}");
+
+ if (WatchHours)
+ // Analytics is a DAILY aggregate, so the tile must not imply live data.
+ tiles.Add(WatchHourGoal > 0
+ ? $"Hours {WatchHoursValue:N0}/{WatchHourGoal:N0} (as of {DateTime.Now:MMM d})"
+ : $"Hours {WatchHoursValue:N0} (as of {DateTime.Now:MMM d})");
+
+ if (SuperChats && state.SessionTotal.Total > 0)
+ tiles.Add(state.HasMixedCurrencies
+ ? $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00} +{state.MoneyByCurrency.Count - 1}"
+ : $"SC {state.SessionTotal.Currency} {state.SessionTotal.Total:0.00}");
+
+ if (NewMembers && state.NewMembers > 0)
+ tiles.Add($"Members +{state.NewMembers}");
+
+ if (Milestones && state.MilestoneCount > 0)
+ tiles.Add($"Milestones {state.MilestoneCount}");
+
+ if (LastMember && !string.IsNullOrWhiteSpace(state.LastMemberName))
+ tiles.Add($"Last member {state.LastMemberName}");
+
+ if (LastSuperChat && !string.IsNullOrWhiteSpace(state.LastSuperChatDisplay))
+ tiles.Add($"Last SC {state.LastSuperChatDisplay}");
+
+ if (LastGift && !string.IsNullOrWhiteSpace(state.LastGifterName))
+ tiles.Add($"Last gift {state.LastGifterName} ×{Math.Max(1, state.LastGiftCount)}");
+
+ return tiles;
+ }
+}
diff --git a/Services/Compositor/ScoreboardPresenter.cs b/Services/Compositor/ScoreboardPresenter.cs
new file mode 100644
index 0000000..29c64e3
--- /dev/null
+++ b/Services/Compositor/ScoreboardPresenter.cs
@@ -0,0 +1,60 @@
+using System.Collections.Generic;
+using ytLive.Models;
+
+namespace ytLive.Services.Compositor;
+
+/// Turns the session's tallies into the strip frame, and keeps the raster until a displayed
+/// value actually moves.
+///
+/// The cache is the point: a strip re-rasterised every tick would be the most expensive layer
+/// the compositor carries, for pixels that did not change. `ScoreboardRecorder.Changed` only fires
+/// on a visible change, so the presenter rebuilds only then.
+public sealed class ScoreboardPresenter
+{
+ private readonly ScoreboardRecorder _recorder;
+ private VideoFrame? _frame;
+ private string? _tilesFingerprint;
+ private ScoreboardStyle _style;
+ private ScoreboardTiles _tiles = ScoreboardTiles.None;
+
+ public ScoreboardPresenter(ScoreboardRecorder recorder, ScoreboardStyle style)
+ {
+ _recorder = recorder;
+ _style = style;
+ _recorder.Changed += Invalidate;
+ }
+
+ /// The strip frame for the current tallies, or null when nothing is enabled/shown.
+ public VideoFrame? Frame
+ {
+ get
+ {
+ var tiles = _tiles.Build(_recorder.State);
+ var fingerprint = $"{_tiles.CacheKey}|{string.Join('|', tiles)}|{_style}";
+ if (_frame != null && fingerprint == _tilesFingerprint) return _frame;
+
+ _tilesFingerprint = fingerprint;
+ _frame = ScoreboardRenderer.Render(tiles, _style);
+ return _frame;
+ }
+ }
+
+ public void SetTiles(ScoreboardTiles tiles)
+ {
+ _tiles = tiles;
+ Invalidate();
+ }
+
+ public void SetStyle(ScoreboardStyle style)
+ {
+ _style = style;
+ Invalidate();
+ }
+
+ /// Drops the cached raster so the next read rebuilds it.
+ public void Invalidate()
+ {
+ _frame = null;
+ _tilesFingerprint = null;
+ }
+}
diff --git a/ytLive.Tests/ScoreboardPresenterTests.cs b/ytLive.Tests/ScoreboardPresenterTests.cs
new file mode 100644
index 0000000..617fbdb
--- /dev/null
+++ b/ytLive.Tests/ScoreboardPresenterTests.cs
@@ -0,0 +1,65 @@
+using System.Threading;
+using Xunit;
+using ytLive.Models;
+using ytLive.Services.Compositor;
+
+namespace ytLive.Tests;
+
+/// The strip's cache is a performance rule with teeth: it must rebuild when a displayed
+/// value moves and must NOT rebuild otherwise, or the compositor pays for a raster per tick into
+/// pixels that did not change.
+public class ScoreboardPresenterTests
+{
+ private static ChatMessage Member(string name) => new() { Kind = ChatEventKind.NewMember, AuthorName = name };
+
+ [Fact]
+ public void The_strip_rebuilds_only_when_a_displayed_value_moves()
+ {
+ System.Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ var recorder = new ScoreboardRecorder();
+ var tiles = new ScoreboardTiles
+ {
+ SuperChats = false, WatchHours = false, Subscribers = false,
+ NewMembers = true, Milestones = false, LastMember = true,
+ LastSuperChat = false, LastGift = false,
+ };
+ var presenter = new ScoreboardPresenter(recorder, new ScoreboardStyle());
+ presenter.SetTiles(tiles);
+
+ // Before a session: nothing counted, nothing to draw.
+ Assert.Null(presenter.Frame);
+
+ recorder.StartSession();
+ recorder.Record(Member("First"));
+ var first = presenter.Frame;
+ Assert.NotNull(first);
+
+ // No change: the SAME frame instance comes back (cache hit).
+ Assert.Same(first, presenter.Frame);
+
+ // A visible change: a new frame.
+ recorder.Record(Member("Second"));
+ var second = presenter.Frame;
+ Assert.NotNull(second);
+ Assert.NotSame(first, second);
+
+ // A gift-recipient event changes nothing displayed, so the raster is reused.
+ recorder.Record(new ChatMessage { Kind = ChatEventKind.GiftReceived, AuthorName = "Recipient" });
+ Assert.Same(second, presenter.Frame);
+
+ // Disabling every tile returns no frame, and the layer draws nothing.
+ presenter.SetTiles(ScoreboardTiles.None);
+ Assert.Null(presenter.Frame);
+ }
+ catch (System.Exception ex) { failure = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+ Assert.Null(failure);
+ }
+}