Templates and code
Every source has a template, CSS and, optionally, JS. You edit them in the editor's Code tab. The defaults come from the module, and you can rewrite any of them: a runner card that looks nothing like ours, a timer with your own markup, a panel built from scratch on a text source.
Templates
A template is HTML with Liquid tags. {{ }} prints a value, {% %} runs logic:
<div class="run">
<div class="game">{{ run.game }}</div>
{% if run.category != "" %}<div class="category">{{ run.category }}</div>{% endif %}
<div class="estimate">{{ "Overlay.estimate" | t }} {{ run.estimateDisplay }}</div>
</div>
The template redraws whenever the live state changes. Only what changed in the page is touched, so animations and embeds don't restart.
Values are escaped, so a game called <b> shows as text. | raw turns that off for a value you trust.
The template can't add <script>, <style>, <form> or event attributes such as onclick; they are removed. Iframes are kept only for Twitch and YouTube players and vdo.ninja.
Variables
| Variable | Contents |
|---|---|
run | the current run: game, category, platform, estimate (ms), estimateDisplay, host, commentators, kind (run, or break, intermission, interview during a block) |
upcoming | next runs and blocks: game, category, runners (names), kind, startsAt |
runners | the runners: name, pronouns, number (1 to 4), finish (ms, display, place, or empty) |
runner | the runner of this source, when it has a runner setting |
timer | display, status (stopped, running, paused, finished), finishes, places |
countdown | display, ended; empty when no countdown is set |
ticker | the ticker messages |
music | artist, title; empty when nothing plays |
donation | amount and total (formatted in the marathon currency) |
mode | single, race or coop |
marathon | name, locale, timezone, currency |
layout | the layout tokens |
settings | this source's settings |
block | inside a block: its variables with this use's values |
now | the server time in milliseconds |
Some modules add their own: clock in the timer, remaining in the countdown, runs in next runs. The list under the template editor shows what the selected source has and inserts it at the cursor.
A race panel that lists every runner:
{% for runner in runners %}
<div class="runner">
<span class="name">{{ runner.name }}</span>
{% if runner.finish %}<span class="time">{{ runner.finish.display }} · {{ runner.finish.place }}º</span>{% endif %}
</div>
{% endfor %}
Filters
Besides the standard Liquid filters (upcase, join, size, date...):
| Filter | Example | Result |
|---|---|---|
duration | {{ run.estimate | duration }} | 1:23:45; "hms" always shows hours, "tenths" adds tenths |
money | {{ donation.amount | money }} | R$ 1.234,56, in the marathon's currency and language |
number | {{ 12345 | number }} | 12.345 |
asset | {{ settings.logo | asset }} | the address of a library image |
t | {{ "Overlay.next" | t }} | "Up next", in the marathon's overlay language |
Animations
Add data-animate to an element and its text animates when it changes: the old text leaves, the new one comes in.
<div class="game" data-animate="flip">{{ run.game }}</div>
Effects: fade, up, down, left, right, flip, explode, implode. The element should hold only text and be a block or inline-block.
Numbers can count instead of swapping. data-countup="money" (or "number") with data-value counts from the old value to the new one in 1.5 s:
<span data-countup="money" data-value="{{ donation.amount }}">{{ donation.total }}</span>
When OBS stops drawing the source (hidden scene, background tab), changes apply at once, so nothing freezes halfway.
CSS
CSS in the CSS tab applies to that source only. Write .game { } and it can't touch a .game in another source or the page around the overlay.
Use the tokens to stay consistent with the layout:
.game {
font: var(--layout-font-heading);
color: var(--layout-color-accent);
}
A token named color-accent is var(--layout-color-accent). Block variables are var(--block-<name>). Shared classes and fonts belong in the layout CSS.
CSS with unbalanced braces is rejected, so a typo can't break the rest of the overlay. Overlays need OBS 31 or newer.
JS
JS never runs in the overlay page itself. A source with JS renders inside a sandboxed frame that can't read cookies, the output key or the rest of the overlay, and can't make network requests. The rendered template goes into #root, and your script hears every update:
speedthon.onState((ctx) => {
const el = document.querySelector("#root .game");
if (el) el.classList.toggle("long", ctx.run.game.length > 24);
});
speedthon.context always holds the latest state. Use JS for what templates and CSS can't do: measuring text, canvas drawing, effects timed by script. For anything else a template is simpler and faster.