diff --git a/Services/Compositor/ScoreboardRenderer.cs b/Services/Compositor/ScoreboardRenderer.cs
new file mode 100644
index 0000000..9c138d8
--- /dev/null
+++ b/Services/Compositor/ScoreboardRenderer.cs
@@ -0,0 +1,139 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using ytLive.Models;
+
+namespace ytLive.Services.Compositor;
+
+/// How the scoreboard strip is painted. Same knobs as the social bar, because the creator
+/// asked for the same controls in the same place (2026-10-30).
+public readonly record struct ScoreboardStyle(
+ string TextColor = "#FFFFFF",
+ bool BackgroundEnabled = true,
+ string BackgroundColor = "#000000",
+ double BackgroundOpacity = 0.55,
+ double FontSize = ScoreboardLayout.MaxFontSize,
+ double SeparatorOpacity = 0.45);
+
+/// Rasterises the scoreboard strip (one line, shrinking/wrapping via
+/// ) into a straight-alpha BGRA frame the compositor blits.
+///
+/// Static by design: the strip holds state, not events — everything enabled is always
+/// visible, nothing waits its turn. It therefore rasterises only when a value changes, which is why
+/// the renderer takes plain strings and the caller keeps the cache.
+public static class ScoreboardRenderer
+{
+ public const int Width = 1920;
+ private const double PaddingX = 24;
+ private const double PaddingY = 8;
+ private const double SeparatorGap = 26;
+
+ public static VideoFrame? Render(IReadOnlyList? tiles, ScoreboardStyle style)
+ {
+ var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList();
+ if (texts is not { Count: > 0 }) return null;
+
+ var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
+ var requested = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize);
+
+ double Measure(string text, double size) =>
+ new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ typeface, size, Brushes.White, pixelsPerDip: 1.0).WidthIncludingTrailingWhitespace;
+
+ var widths = texts.Select(t => Measure(t, requested)).ToList();
+ var fit = ScoreboardLayout.FitLine(widths, SeparatorGap, Width - PaddingX * 2, requested);
+
+ // Re-measure at the fitted size so line breaking and drawing use the same numbers.
+ var size = fit.FontSize;
+ var lines = BuildLines(texts, texts.Select(t => Measure(t, size)).ToList(), Width - PaddingX * 2);
+ var lineHeight = size * 1.45;
+ var height = (int)Math.Ceiling(lines.Count * lineHeight + PaddingY * 2);
+
+ var textBrush = Brush(style.TextColor, 1.0, Colors.White);
+ var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black);
+ var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray);
+
+ var visual = new DrawingVisual();
+ using (var dc = visual.RenderOpen())
+ {
+ if (style.BackgroundEnabled)
+ dc.DrawRoundedRectangle(plate, null, new Rect(0, 0, Width, height), height / 2.0, height / 2.0);
+
+ for (var line = 0; line < lines.Count; line++)
+ {
+ var lineTiles = lines[line];
+ var totalWidth = lineTiles.Sum(t => Measure(t, size)) + SeparatorGap * (lineTiles.Count - 1);
+ var x = (Width - totalWidth) / 2;
+ var y = PaddingY + line * lineHeight + (lineHeight - size) / 2;
+
+ for (var i = 0; i < lineTiles.Count; i++)
+ {
+ var text = Text(lineTiles[i], typeface, size, textBrush);
+ dc.DrawText(text, new Point(x, y));
+ x += text.WidthIncludingTrailingWhitespace;
+
+ if (i == lineTiles.Count - 1) continue;
+ var dot = Text("·", typeface, size, separator);
+ dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, y));
+ x += SeparatorGap;
+ }
+ }
+ }
+
+ var bitmap = new RenderTargetBitmap(Width, height, 96, 96, PixelFormats.Pbgra32);
+ bitmap.Render(visual);
+
+ var pixels = new byte[Width * height * 4];
+ bitmap.CopyPixels(pixels, Width * 4, 0);
+ for (var i = 0; i < pixels.Length; i += 4)
+ {
+ var a = pixels[i + 3];
+ if (a == 0 || a == 255) continue;
+ pixels[i] = (byte)(pixels[i] * 255 / a);
+ pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
+ pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
+ }
+ return new VideoFrame(Width, height, pixels);
+ }
+
+ /// Greedy line packing at the fitted size — mirrors
+ /// so the break points agree with the fit the caller was told about.
+ private static List> BuildLines(IReadOnlyList tiles, IReadOnlyList widths, double available)
+ {
+ var lines = new List> { new() };
+ var lineWidth = 0.0;
+ for (var i = 0; i < tiles.Count; i++)
+ {
+ var w = widths[i];
+ var needed = lineWidth == 0 ? w : lineWidth + SeparatorGap + w;
+ if (needed > available && lineWidth > 0)
+ {
+ lines.Add(new List());
+ lineWidth = w;
+ }
+ else
+ {
+ lineWidth = needed;
+ }
+ lines[^1].Add(tiles[i]);
+ }
+ return lines;
+ }
+
+ private static FormattedText Text(string text, Typeface face, double size, Brush brush) =>
+ new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, face, size, brush, pixelsPerDip: 1.0);
+
+ private static SolidColorBrush Brush(string hex, double opacity, Color fallback)
+ {
+ Color colour;
+ try { colour = (Color)ColorConverter.ConvertFromString(hex); }
+ catch (Exception) { colour = fallback; }
+ var brush = new SolidColorBrush(Color.FromArgb((byte)Math.Round(opacity * 255), colour.R, colour.G, colour.B));
+ brush.Freeze();
+ return brush;
+ }
+}
diff --git a/ytLive.Tests/ScoreboardRendererTests.cs b/ytLive.Tests/ScoreboardRendererTests.cs
new file mode 100644
index 0000000..5c63460
--- /dev/null
+++ b/ytLive.Tests/ScoreboardRendererTests.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using Xunit;
+using ytLive.Services;
+using ytLive.Services.Compositor;
+
+namespace ytLive.Tests;
+
+/// The strip is state, not events: everything enabled is always on screen. These checks
+/// paint the real frame, because a renderer that silently draws nothing would still pass a
+/// structural test.
+public class ScoreboardRendererTests
+{
+ private static readonly List Tiles = new() { "Members 12", "Milestones 3", "SC $47.00" };
+
+ [Fact]
+ public void The_strip_paints_the_plate_and_the_text_and_never_needs_a_scroll()
+ {
+ Exception? failure = null;
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ // Plate on: half-transparent blue behind white text.
+ var plated = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FFFFFF", true, "#0000FF", 0.5));
+ Assert.NotNull(plated);
+ Assert.Equal(ScoreboardRenderer.Width, plated!.Width);
+ Assert.True(plated.Height > 0);
+
+ Assert.True(Count(plated, (r, g, b, a) => b > 150 && r < 60 && g < 60 && a is > 100 and < 170) > 500,
+ "the plate must fill at the chosen colour and opacity");
+ Assert.True(Count(plated, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200) > 20,
+ "the text must be painted in the chosen colour");
+
+ // Plate off: the same area is transparent, the text survives.
+ var bare = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FFFFFF", false, "#0000FF", 0.5));
+ Assert.NotNull(bare);
+ Assert.Equal(0, Count(bare!, (r, g, b, a) => b > 150 && r < 60 && g < 60 && a > 100));
+ Assert.True(Count(bare!, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200) > 20);
+
+ // Text colour reaches the glyphs.
+ var red = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FF0000", false, "#000000", 0.5));
+ Assert.True(Count(red!, (r, g, b, a) => r > 180 && g < 80 && b < 80 && a > 200) > 20);
+
+ // Nothing enabled: no frame at all (the layer draws nothing).
+ Assert.Null(ScoreboardRenderer.Render(Array.Empty(), new ScoreboardStyle()));
+ }
+ catch (Exception ex) { failure = ex; }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ thread.Join();
+
+ Assert.Null(failure);
+ }
+
+ private static int Count(VideoFrame frame, Func predicate)
+ {
+ var hits = 0;
+ for (var i = 0; i < frame.BgraPixels.Length; i += 4)
+ if (predicate(frame.BgraPixels[i + 2], frame.BgraPixels[i + 1], frame.BgraPixels[i], frame.BgraPixels[i + 3]))
+ hits++;
+ return hits;
+ }
+}