Browse the docs

Math Engine

Waves, modulo wheels, pseudo-random shaders, timestamp racing, and the expression tricks that make overlays feel alive - built entirely in a text field.

Guide 12 min read
.md
On this page
  1. The toolbox
  2. The heartbeat: now()
  3. Waves from trigonometry
  4. Sawtooth, ramps, and fract()
  5. Decoded: the pseudo-random one-liner
  6. The modulo wheel
  7. Clamp, round, abs: the cleanup crew
  8. Winners and timestamp racing
  9. Live Twitch values: the t.* namespace
  10. Pitfalls and things that will not work
  11. Now go build something weird

This page is for the math-heads. If you came here for gentle examples, try Conditionals or Formatting Pipes first. Still here? Good. Put on the goggles.

The toolbox

Everything the engine understands. Anything not on this list is intentionally absent - no eval, no new Function, no prototype walking. The sandbox is the point.

Operators

+  -  *  /  %    ==  !=  >  <  >=  <=    &&  ||  !    ? :

No exponentiation operator (**, ^). For squares, write x * x. The ternary a ? b : c is available, including nested form.

Constants and namespaces

  • PI - . Identifier only, not PI().
  • c.* - your controls (c.wins, c.kofi.donations_received, etc.).
  • t.* - live Twitch event tags fed by EventSub: t.followers_total, t.subscribers_total, t.last_cheer_bits, t.last_raid_from, and so on. Every tag that a channel.* rule mutates is readable here.

Rule of thumb: use [[[tag]]] in HTML (parsed once at render), t.tag in expression controls (live and reactive). The two substrates never parse each other's syntax.

Scalar math

Call Meaning
max(a, b, ...) Largest of the args
min(a, b, ...) Smallest of the args
clamp(x, lo, hi) x pinned to [lo, hi]
sum(a, b, ...) Arithmetic sum
avg(a, b, ...) Arithmetic mean
abs(x) |x|
round(x) / round(x, n) Nearest integer, or n decimals
floor(x) / ceil(x) Round toward −∞ / +∞
sin(x) / cos(x) / tan(x) Trig, x in radians
asin(x) / acos(x) / atan(x) Inverse trig, returns radians
atan2(y, x) Angle from coordinates, all four quadrants. Pair with sqrt for haversine
sqrt(x) Square root. Returns 0 for negative x
fract(x) x − floor(x). Always ∈ [0, 1)
mod(a, b) Floor-modulo (GLSL-style, not JS %)
now() Unix timestamp in seconds (integer)
now_ms() Unix timestamp in milliseconds - for sub-second animation

Argument-pair family

Take value/label pairs and return the label paired with the winning value. Ties go to the first pair.

argmax(v1, l1, v2, l2, ...)
argmin(v1, l1, v2, l2, ...)
latest(v1, l1, v2, l2, ...)    // alias of argmax, but intent: timestamps
oldest(v1, l1, v2, l2, ...)    // alias of argmin

Values are coerced to numbers. Strings that parse as numbers work. Strings that look like ISO dates are parsed as milliseconds since epoch.

The heartbeat: now()

Every time-based trick in this page reduces to one identity. Let be the current Unix time in seconds. Time only matters once you take its fractional part, feed it through trig, or compare it to another timestamp. Three tricks cover most of the design space.

Overlabels also stamps every control with an automatic companion: c:key_at is the Unix timestamp of the last write to c:key. This is what turns latest() into a cross-service race.

Elapsed seconds since an event

Any control's _at companion is a Unix timestamp you can subtract now() from. Pick any event-driven control - for example, the one that stores the latest follower - and you can show how long ago it fired.

now() - t.followers_latest_date_at

Pipe the result through |duration:mm:ss and you have a "last follow was N minutes ago" display built from subtraction alone.

Waves from trigonometry

The canonical animation primitive. A sine wave with amplitude , period , and baseline :

Map that one formula to controls and you have a breathing badge, a pulsing circle, a lighthouse sweep, or a subtle bob. Every example here uses now_ms(), so is written in milliseconds - a 6-second period is 6000:

// 1 Hz pulse, mapped to 0..1 (use as opacity / scale normaliser)
0.5 + 0.5 * sin(2 * PI * now_ms() / 1000)

// Slow breathe, ±5% around 1.0, period 6 s
1 + 0.05 * sin(2 * PI * now_ms() / 6000)

// Lighthouse sweep, 0..1 once every 8 s (always positive)
abs(sin(PI * now_ms() / 8000))

These will not work with now(). It returns whole seconds, so it feeds a wave a handful of distinct values per cycle and you get a staircase. The 1 Hz pulse degenerates completely: sin(2 * PI * now()) is for integer , which is always zero, so the whole expression sits at a constant 0.5 and never moves. Anything continuous takes now_ms(). See section 10.

The generalised remap from into any range is a template worth memorising:

Lissajous figures on two controls

Drive an X offset with sin and a Y offset with cos at different frequencies. Two control expressions, one orbit:

// c:orbit_x
40 * sin(2 * PI * now_ms() / 5000)

// c:orbit_y (3:2 frequency ratio -> a classic Lissajous)
40 * cos(2 * PI * now_ms() / 7500)

Sawtooth, ramps, and fract()

fract(x) = x - floor(x). It discards the integer part and keeps the fraction. Feed it a rising quantity and you get a sawtooth: a 0 → 1 ramp that snaps back to zero forever.

// 10-second loop, ramps 0 -> 1
fract(now_ms() / 10000)

// Same loop, reversed: 1 -> 0
1 - fract(now_ms() / 10000)

// Triangle wave via abs of a shifted sawtooth: 0 -> 1 -> 0 every 4 s
abs(2 * fract(now_ms() / 4000) - 1)

The triangle trick deserves its own line. Start with a sawtooth, scale it to , subtract 1 to centre on zero, then take the absolute value. You just built a piecewise-linear tent function from two primitives.

Decoded: the pseudo-random one-liner

This expression returns a seemingly random integer from 1 to 9, changing once per second:

floor(fract(sin(now() / 2) * 1000) * 9) + 1

It is a variant of the classic shader-language pseudo-random trick . It is not cryptographic - do not roll dice in a contract with it - but for visual sparkle it is beautiful. Let us take it apart.

  1. now() / 2 - time, advancing by 0.5 on each once-per-second tick. Any monotonically-rising value works here. Dividing slows the churn.
  2. sin(...) - maps the growing input into . On its own, too smooth to be random.
  3. ... * 1000 - scales that smooth wave up. The integer part of the result is now big and varied; the fractional part is where the chaos lives. Multiplying by a large number amplifies how fast the fraction tumbles as changes.
  4. fract(...) - throws away the integer part and keeps only the chaotic tail. The output is now in and, from the user's perspective, indistinguishable from noise.
  5. ... * 9 - stretches that unit-interval noise into .
  6. floor(...) + 1 - snaps to an integer in , then shifts to .

Equivalent formulation, in case you prefer to read it in math:

Variants

// Uniform-ish [0, 1) noise (no integer snap)
fract(sin(now()) * 43758.5453123)

// Roll a 20-sided die every 3 seconds
floor(fract(sin(floor(now() / 3)) * 9999) * 20) + 1

// "Pick one of three overlays" every 10 s, using mod
mod(floor(fract(sin(floor(now() / 10)) * 9999) * 3), 3)

Note the floor(now() / N) trick: quantising time before you sin it turns a continuously-changing value into a step function. The "random" output then stays stable for N seconds before jumping, which is what you actually want for most UI.

The modulo wheel

mod(a, b) in Overlabels is floor-modulo, the one mathematicians wrote on the chalkboard:

Always non-negative when , even for negative . Contrast with the JS % operator, which preserves sign. Use mod when you are indexing something cyclic.

// Cycle 0 -> 1 -> 2 -> 0 every 5 s
mod(floor(now() / 5), 3)

// Cycle through the days of the year (day-of-year)
mod(floor(now() / 86400), 365)

// Ping-pong 0 -> 1 -> 0 smoothly: triangle then normalise
abs(2 * fract(now_ms() / 6000) - 1)

Pair mod with a conditional to rotate overlay text:

// c:banner_index =>
mod(floor(now() / 8), 3)

// In HTML:
[[[if:c:banner_index = 0]]]Welcome, [[[channel_name]]]![[[endif]]]
[[[if:c:banner_index = 1]]]Follow to join [[[followers_total]]]+ friends.[[[endif]]]
[[[if:c:banner_index = 2]]]!commands for the full list.[[[endif]]]

Clamp, round, abs: the cleanup crew

The engine's cleanup functions exist so you can pipe raw inputs into CSS without worrying about extremes, floats, or negative values.

Clamp as a saturation limiter

// Hype meter: 0..100, never overshoots, never negative
clamp(c.cheer_bits / 100, 0, 100)

Round for display, keep precision internally

Trig output has 15 decimal places you never want to show. Round at the edge of the UI.

// Win rate as a clean percentage
round(c.wins / (c.wins + c.losses) * 100, 1)

abs(sin) as a one-sided wave

Taking the absolute value of a sine folds the negative half up. You get twice the frequency visually and a lighthouse-style pulse that never dips below zero. Great for "intensity".

Winners and timestamp racing

This is the trick the rest of the streaming ecosystem does not have. Every control in Overlabels has an automatic _at companion that stores the Unix timestamp of the last time its value changed. That means you can race signals:

// Who tipped most recently - Ko-fi, Streamlabs, or Fourthwall?
latest(
  c.kofi.donations_received_at, c.kofi.latest_donor_name,
  c.streamlabs.donations_received_at, c.streamlabs.latest_donor_name,
  c.fourthwall.donations_received_at, c.fourthwall.latest_donor_name
)

Race the counter's timestamp, not the name's. Since _at tracks changes, a donor tipping twice in a row leaves latest_donor_name_at untouched - the name was already theirs. donations_received increments on every donation whoever sent it, so its _at is the honest arrival time.

The value at each odd position is a timestamp; the even position next to it is the label you want returned. latest() picks the biggest timestamp and returns its paired label. oldest() / argmin() do the opposite - perfect for "slowest response", "first to arrive", "longest since".

A service you have never connected is safe to leave in the list: the reference resolves to nothing, which races as and always loses. Nothing on a pipe means nothing to show.

Sum across services

// Unified donation counter
c.kofi.donations_received + c.streamlabs.donations_received + c.fourthwall.donations_received

// Unified total received amount
c.kofi.total_received + c.streamlabs.total_received + c.fourthwall.total_received

"Is this subscriber actually a gift?"

Because t.subscribers_latest_is_gift is a boolean stamped by the channel.subscribe EventSub rule, you can build sentiment directly:

// Who to thank for the most recent sub
t.subscribers_latest_is_gift
  ? t.subscribers_latest_gifter_name + " gifted a sub to " + t.subscribers_latest_user_name
  : t.subscribers_latest_user_name + " just subscribed"

The ternary is your friend. Chain them for switch-like behaviour: a ? x : b ? y : z.

Live Twitch values: the t.* namespace

The t.* namespace exposes every tag that EventSub mutates - follower totals, the latest cheer user, peak raid viewers, the latest sub's gift flag, and so on - directly in expressions. These are live: when a follow fires, t.followers_total increments and any expression that reads it re-evaluates on the next tick.

The relationship to static [[[tag]]] syntax is simple: the same values that appear under [[[followers_total]]] in your HTML also appear under t.followers_total in expressions. HTML tags are resolved once at render; expression values stay reactive forever.

Progress to the next milestone

A horizontal progress bar that fills from 0 to 100 as your follower count approaches the next thousand, then snaps back to zero and starts climbing again:

// c:milestone_pct ->
clamp((t.followers_total - floor(t.followers_total / 1000) * 1000) / 10, 0, 100)

That is wearing a clamp guard. Wire it into CSS: style="width: [[[c:milestone_pct]]]%".

Fade in the latest follower's name

Every tag has an automatic _at Unix timestamp companion. Combine it with now_ms() and clamp to get a two-second fade-in on every new follow:

// c:greet_opacity ->
clamp((now_ms() - t.followers_latest_user_name_at * 1000) / 2000, 0, 1)

Note the * 1000. Every _at companion is Unix seconds, so it has to be lifted into milliseconds before it can be subtracted from now_ms(). Forget it and you get a number around 1.7 billion, which clamp dutifully pins to 1 - a fade that is always finished. With now() on both sides the units line up, but a two-second fade would only have three frames to play with, so now_ms() is the right tool and the multiply is the price.

Greeting copy that switches on the event shape

// c:greet_text ->
t.subscribers_latest_is_gift
  ? "Thanks " + t.subscribers_latest_gifter_name + " for gifting a sub to " + t.subscribers_latest_user_name + "!"
  : "Welcome " + t.subscribers_latest_user_name + "!"

Raid hype meter

// Scale from 0..1 based on peak raid size, saturating at 500 viewers
// c:raid_hype ->
clamp(t.last_raid_viewers_peak / 500, 0, 1)

// "who raided me" label, or empty if no raid yet
// c:raid_label ->
t.last_raid_from ? t.last_raid_from + " raided with " + t.last_raid_viewers_peak + " viewers" : ""

[[[tag]]] syntax does not work inside expression strings - expressions never reparse template-tag syntax, and template tags never evaluate expressions. Use t.tag in expressions, [[[tag]]] in HTML. That separation is how the engine stays secure.

Pitfalls and things that will not work

Time resolution: now() vs now_ms()

In a live overlay, any expression containing now() or now_ms() is re-evaluated on a shared requestAnimationFrame ticker - once per display frame, so roughly 60 times a second. Both functions ride the same loop and neither is throttled, so time-based formulas update on their own with no heartbeat control needed.

What differs is the value, and the value is what you actually see move:

  • now() returns integer seconds. It hands back the same number for ~60 consecutive frames, and the ticker skips the write when the result has not changed - so anything derived from it visibly changes once per second. Perfect for clocks, uptimes, banner rotations.
  • now_ms() returns milliseconds. It is a new number on every frame, so it drives genuinely smooth motion. Use it for anything sub-second: mod(floor(now_ms() / 250), 3) to step 4x a second, or sin(now_ms() / 500) for a smooth wave that cycles about every 3.1 s.

Swapping one function for the other rescales your whole formula by 1000x. sin(now_ms() / 600) completes a cycle every 3.8 seconds; sin(now() / 600) completes one every 63 minutes. The second is not running slower - it is a different wave. When you switch to now(), shrink your divisor by 1000.

Rescaling cannot rescue now() for animation, either. Speed it up enough to see and its once-per-second steps become visible jumps, so you get a staircase instead of a wave. Smooth motion is now_ms(), always.

For purely decorative motion (opacity pulses, CSS transforms) CSS animations remain the better tool - not because they are faster, but because they run on the compositor without writing overlay data or re-rendering anything. Reach for expressions when the time-driven value is state that other parts of the overlay need to read.

The editor preview does not tick

The preview inside the expression control editor re-evaluates when you change the text, not on a timer. A time-based formula looks frozen there no matter which function it uses. The preview exists to confirm that an expression parses and produces a sane value - save it into an overlay to watch it move.

Radians, not degrees

sin(90) is not 1. It is . Use or just work in multiples of PI directly.

No exponentiation operator

x ** 2 and x ^ 2 do nothing useful. For write x * x. For write x * x * x. There is also no log or exp - the function whitelist is deliberately small for security and bundle size. sqrt and the full trig surface (tan, asin, acos, atan, atan2) are available for spatial math.

Division by zero returns zero

The engine swallows and returns 0 instead of Infinity. This is deliberate: an overlay should never crash on a zero denominator. Write defensively anyway - c.wins / (c.wins + c.losses) returns 0 on the fresh account, not the you might expect.

Odd argument count in arg-family

latest(a, b, c) with three arguments returns the literal error string "⚠ Odd argument count - needs value, label pairs". The engine is telling you to pair every value with a label.

Floating-point sins

. When rendering, round at the edge: round(expr, 2) or the |round:2 pipe. Never compare floats with == - use abs(a - b) < 0.001.

Now go build something weird

The evaluator fits in one file - resources/js/lib/expression-engine/engine.mjs, shared by the overlay and the server-side sidecar - and the whole whitelist is readable in about ten seconds. Every function above is a primitive you can combine. The real power is in what you chain together.

Want the companion pages? Controls, Conditionals, Formatting Pipes.

Related docs

Reading this as a machine? The same page as plain markdown: /help/math.md