Skip to content

Custom graphics API

A Custom graphic layer runs your own HTML, CSS and JavaScript inside a Studio overlay. You write it beside a live preview, and it saves, publishes and reaches OBS with the rest of the overlay. Your code talks to Bloopbot through one global object: bloop.

New to custom graphics? Follow Author a custom graphic first. For GSAP, PixiJS, Three.js and Phaser, see Graphics libraries.

How a graphic works

A graphic shows channel data; it never changes it. Stream behaviour stays in flows. To read records, leaderboard boards or Stage queues, build a page with the Overlay SDK instead.

Your first graphic

Every new graphic starts as this raid card. It lists GSAP in libraries, declares the raid event and has one colour control called accent.

HTML
<article id="card">
  <span id="caption">READY TO PLAY</span>
  <strong id="name">Your next great moment</strong>
</article>
CSS
html, body { margin: 0; background: transparent; }
body { font-family: system-ui, sans-serif; color: #fff; }
#card {
  padding: 32px 40px;
  border-radius: 20px;
  background: #121419;
  border-bottom: 6px solid var(--accent, #467bff);
}
#caption { display: block; font-size: 18px; letter-spacing: .12em; margin-bottom: 12px; }
#name { display: block; font-size: 48px; line-height: 1.1; }
JavaScript
document.documentElement.style.setProperty('--accent', bloop.settings.accent);

bloop.onEvent('raid', (event) => {
  document.querySelector('#caption').textContent = 'WELCOME, RAIDERS';
  document.querySelector('#name').textContent = event.name || 'A new party has arrived';
  bloop.timeline()
    .fromTo('#card', { y: 40, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6 })
    .to('#card', { opacity: 0, y: -20, duration: 0.4 }, 5.6);
});

To try it, pick an event in the workspace's Event list, edit the sample JSON if you like, and choose Send event. Each event type has its own sample, and switching type replaces the fields. A message with declared fields gets one input per field, prefilled from its contract.

Get events from flows

A graphic never listens to Twitch directly. A flow's Send to overlay step delivers to it.

JavaScript
bloop.onEvent('follow', (event) => console.log(event.name));

bloop.onEvent('custom', (event) => {
  if (event.name === 'round-start') console.log(event.data);
});

When you write the flow yourself

Set Send to overlay's format (action.widget's format) to the alert type, such as follow, so the graphic receives onEvent('follow', …). Pass the viewer's name and any numbers or text as Values. Choosing a format in the flow editor fills these rows from the matching trigger: gift uses $(gifter) and $(count), cheer $(bits) and raid $(viewers). Changing the type replaces the Values in one undoable edit; saved flows are never rewritten.

The default format, custom, delivers { type: 'custom', name, data } to onEvent('custom', …).

Through MCP, copy the chosen format's optionConfig[format].data from describe_node into the node's config.data yourself. An assistant that adds an event to a graphic also needs the matching flow tool calls.

Messages with fields

A message can declare the values it carries. That gives the flow editor something to check and gives Studio's completions a type for event.data.

JSON
{ "name": "beanboozled", "fields": { "result": ["safe", "boozled"], "total": "number" } }

Animate on the clock

Studio can pause, scrub and replay a graphic, and previews render a chosen moment. That only works when everything that moves follows the graphic's clock:

You use Do this
GSAP Build timelines with bloop.timeline(). They start paused and follow the clock. A bare gsap.to() runs on GSAP's own clock.
CSS animations Nothing. Studio pauses and advances them for you, including ones an event starts.
Delays bloop.schedule(ms, fn), never setTimeout.
PixiJS, Three.js, Phaser, canvas Stop the library's loop and render from bloop.onTick. Graphics libraries has an example for each.

Seeking rebuilds the graphic and replays its events in time order; Studio keeps the latest 256. Loop replays the selected sample at the start of each cycle; turn it off for one-shot playback. Reset starts a fresh run. An error in one callback shows in Output and does not stop the others.

Play alerts one at a time

bloop.queue runs events in turn on the clock. Return how long the event lasts, in milliseconds, or the timeline you built. This one uses libraries: ["gsap", "gsap/SplitText"] and a sound declared as whoosh:

JavaScript
bloop.queue((event) => {
  const name = document.querySelector('#name');
  name.textContent = String(event.name ?? 'Raid party');
  const letters = new SplitText(name, { type: 'chars' }).chars;
  return bloop.timeline()
    .call(() => bloop.sound('whoosh', { volume: 0.8 }))
    .from(letters, { y: 30, opacity: 0, stagger: 0.03, duration: 0.4 })
    .to('#card', { opacity: 0, duration: 0.4 }, '+=4');
}, { gapMs: 500 });

Fonts, sound and images

Fonts

List each family in fonts, then load it in CSS from Google Fonts or from an uploaded WOFF2, WOFF, TTF or OTF file declared in assets:

CSS
@import url('https://fonts.googleapis.com/css2?family=Creepster&display=swap');

@font-face {
  font-family: 'Brand Sans';
  src: url(asset:brand_font);
}
#name { font-family: Creepster, 'Brand Sans', sans-serif; }

The script runs first. Then the graphic waits up to three seconds for the listed fonts before it replays events and handles the first one, so a first alert measures text in its real font. Measure or split text inside event handlers, not at the top level. A font that does not load is named in Output.

Sound

bloop.sound('whoosh', { volume: 0.8 }) plays a declared media file. Its volume is multiplied by the layer's Sound volume (volume, default 0.8), which the streamer sets without code. Sounds stay silent while a moment is rebuilt (seeking, scrubbing, previews) or when the clock jumps past them. At most eight play at once. It never throws: a name you did not declare is reported once in Output and stays silent.

Images and effects

HTML
<svg width="0" height="0">
  <filter id="goo">
    <feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur" />
    <feColorMatrix in="blur" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 20 -8" />
  </filter>
</svg>
<div class="blobs" style="filter: url(#goo)">…</div>

feTurbulence with feDisplacementMap gives a liquid wobble; animate stdDeviation or scale from a timeline. feImage may only point at blob: or data: URLs, such as bloop.assets and loadImage results.

Themes and custom CSS

A graphic draws inside its overlay's theme, like every other layer. bloop.theme is that theme for this layer, and bloop.onTheme(theme => …) receives it immediately and again whenever the streamer switches themes or changes the layer's style or the overlay's custom CSS. The graphic is not rebuilt and nothing replays.

Field Contents
colors The theme's accent, panel and text colours as #RRGGBB: what a theme:accent, theme:panel or theme:text colour setting stands for
vars The theme's CSS custom properties, already set on #graphic: --w-text, --w-accent, --w-panel, --w-panel-opacity, --w-panel-fill, --w-radius, --w-padding-x, --w-padding-y, --w-border-width, --w-border-color, --w-letter-spacing, --w-line-height, --w-font-weight, --w-font-family, --w-font-body-weight, and --w-bi-panel-*, --w-bi-item-*, --w-bi-alert-* for the theme's picture frames (--w-bi-…-source, the picture itself, only when your source names it)
css The theme's part styles and the overlay's custom CSS aimed at this graphic, already on the page after your own CSS
fonts The font families the theme loaded for this graphic
tokens The theme's other choices: design, chatBubbleWidth, shadow, panelBackground, itemBackground, alertBackground (a picture or video carries url, a local blob URL, when your source names that background), motion, and a built-in's parts

Use the variables in your CSS so the graphic follows the theme:

CSS
.card {
  color: var(--w-text);
  background: var(--w-panel-fill);
  border-radius: var(--w-radius);
  font-family: var(--w-font-family);
  font-weight: var(--w-font-body-weight);
}

The theme's font is not applied for you, so an existing graphic keeps its own; write font-family: var(--w-font-family); font-weight: var(--w-font-body-weight); where you want it. The theme's fonts (its Google font, part fonts, Inter and Bloop Display) load, and hold the first event as fonts does, only for what a graphic paints in: your source, its part styles or a custom CSS rule aimed at it uses --w-font-family, or names the family. A theme's picture frame or background picture or video (up to 25 MB) is sent only to a graphic whose source names it: write var(--w-bi-panel-source) or bloop.theme.tokens.panelBackground out in full. Blob URLs in bloop.theme are replaced when the file changes, so read them again in onTheme.

A colour setting may hold a theme colour (theme:accent, theme:panel or theme:text) instead of a hex, so the graphic follows a theme switch. bloop.themeColor(value) turns either into the colour to draw: a reference as the theme's current #RRGGBB, a hex unchanged. Call it again in onTheme:

JavaScript
bloop.onTheme(() => {
  document.querySelector('.card').style.borderColor = bloop.themeColor(bloop.settings.border);
});

#graphic carries the layer's attributes, as the layer's box does on the overlay page: class="w-layer", data-layer-id, data-layer-kind (graphics, or a built-in's kind) and data-shadow. The overlay's custom CSS reaches the graphic through the rules naming one of the .w-* classes in its source, or its layer ([data-layer-id="…"]); they load after your own CSS, so the streamer wins a tie, as on the page. A class inside :is(), :where(), :not() or :has() does not count, and a rule that already styles the layer's frame on the page ([data-layer-id="…"] > *) stays there. A rule for the layer's box itself (.w-layer, [data-layer-id="…"]) or for :root, html or body brings only the custom properties your source or a kept rule uses, at the lowest priority and before your own CSS, so a property you define yourself always wins. The @font-face, @keyframes, @property and @counter-style rules a kept rule uses come the same way, so your own of the same name wins. Any other rule (img, .title) stays on the page, and a graphic with no w- class and no rule for its layer gets nothing from the overlay's custom CSS. Give the parts a streamer may want to restyle w- class names, written out in full in your source.

The bloop object

bloop.version is "1.2". Every on… method, schedule and queue returns a function that cancels it.

Data

Member What you get
settings Current values of your controls, frozen all the way down (lists included)
assets Local URLs for declared media, by name, frozen
getVariables() Current values of declared variables
onVariables(fn) The current values now, then every change
onEvent(type, fn) Each declared event as a detached JSON object; an undeclared type throws
onAction(action, fn) Runs when the streamer presses a declared button whose action is action, in Studio and on stream; never through onEvent. An undeclared action throws
queue(fn, { events?, gapMs?, max? }) Events one at a time. gapMs defaults to 0; events narrows which types; up to max wait (default 50, at most 500) and newer ones are dropped with a warning

Time and size

Member What you get
timeMs The clock's current time
size { width, height, pixelRatio }. pixelRatio counts the overlay's own scaling and the screen
onTick(fn) { timeMs, deltaMs } now (delta 0), then on every clock advance
onResize(fn) The size now, then on every change
timeline() A paused GSAP timeline on the clock (needs gsap)
schedule(ms, fn) Runs once, 0–3,600,000 ms from now; cancelled on dispose
onDispose(fn) Runs once before the graphic is removed. Studio rebuilds on every seek and code change, so release what you create here

Media and drawing

Member What you get
sound(asset, { volume? }) Plays a declared file by name or URL at volume (0–1, default 1) × the layer volume. Returns { stop() }; never throws
canvas({ width?, height?, context?, parent?, draw? }) A 2d, webgl or webgl2 canvas, at most 4,096 pixels a side. draw receives { context, canvas, width, height, pixelRatio, timeMs, deltaMs }. Returns { canvas, context, width, height, pixelRatio, redraw(), destroy() }
loadImage(url) A promise of a local URL for a remote image (see Images and effects). Kept for a day, least recently used dropped past 128 MB

Theme

Member What you get
theme The overlay theme this layer draws with, frozen: { colors, vars, css, fonts, tokens } (see Themes and custom CSS)
onTheme(fn) The theme now, then every change, without a rebuild
themeColor(value) A colour setting as it draws now: theme:accent, theme:panel or theme:text as the theme's #RRGGBB, a hex unchanged

Settings reference

A graphic's settings object. Studio's Data & settings tab edits all of it; you only write it by hand through MCP.

Field What it holds
version Always 1
html, css, javascript Your source, up to 100,000 characters each
language Optional. "ts" when javascript holds TypeScript; absent (or "js" on input) means JavaScript. New graphics start as "ts". Before Studio, OBS, preview_overlay or a marketplace build runs it, it is compiled to JavaScript: types are erased, never checked at run time. A syntax error runs no script; Studio's console and MCP compileWarnings name its line. A compile that takes longer than 2 seconds is stopped and reported the same way, and pauses compiling for the channel for a minute
libraries Libraries, plugins and add-ons to load, such as ["gsap", "gsap/SplitText"]. Names and versions: Graphics libraries
events Event types the graphic receives, such as raid, follow, chat, goal and poll
messages Optional. Up to 64 message names (1–32 lowercase letters, digits, hyphens or underscores), or { name, fields }
variables Up to 32 channel variable keys (1–64 lowercase letters, digits or underscores)
assets Up to 32 names mapped to media from this channel's library
fonts Optional. Up to 8 font family names, unquoted (1–64 letters, digits, spaces, hyphens or underscores)
volume Optional. The layer's sound level, 0–1, default 0.8
controls Up to 64 settings the streamer can change without code, counting those inside groups and lists (below)
values The streamer's values for those controls; missing ones use the default
presets Optional. Up to 16 { id, label, values } buttons that fill in the values they list
durationMs Studio preview length, 100–60,000 ms

Controls

A control has key (a lowercase letter, then up to 47 letters, digits or underscores; camelCase allowed), label (1–80 characters), optional help (up to 280 characters) and optional showWhen. showWhen is {key, equals} (a value or a list of values), {key, notEquals}, or a list of up to four that must all hold. It names another one-value setting at the same level and only hides the control in the editor.

Type Fields bloop.settings holds
text default, multiline?, maxLength? (up to 1,000) a string
color default (#rgb, #rrggbb, #rrggbbaa), alpha?, themeRefs? (also accept theme:accent, theme:panel or theme:text) a string: the colour, or the theme reference exactly as chosen, so your code can follow the theme
number default, min?, max?, step?, unit?, slider? a number (within ±1,000,000)
flag default true or false
select options (up to 32 strings or {value, label}), default, display? (dropdown, segmented), multiple? the chosen value, or with multiple a list
font default (a family name, or "") the family; a Google Fonts family is loaded before the first event
gradient default, like linear-gradient(180deg, #467bff 0%, #121419 100%) or radial-gradient(circle, …), 2–4 hex stops in order the CSS gradient
asset accept (image, audio, video, font), default (a library id or null), multiple?, max? (up to 100) a local blob URL or null; with multiple a list of URLs
record record (alert_design, wheel, giveaway, leaderboard), default the design name, id or board key, or null
variable default, scope? (channel, viewer), data? the variable key or null; a channel variable is delivered to getVariables()
list item (the controls each item holds: no list, group or action), default items, min?, max? (up to 100), itemLabel? an array of objects, each item’s defaults filled in
group controls (no group inside), collapsed? nothing of its own: its controls’ values sit at the top level
action action nothing: a button

A widget action button’s action is a lowercase name. Pressing it in Studio runs bloop.onAction(action, handler) in the Studio preview and on every open OBS source, and never reaches onEvent or a queue. MCP previews press one with {type:"custom", name:"action:<name>", data:{}}. An action of the form capability.verb, such as countdown.start, is refused until the graphic declares that capability. Settings, list items and presets are validated together, and every value must match its control.

Naming rules

Build and test through MCP

An AI assistant connected through MCP can write and check a graphic without OBS:

Packs carry the source, controls and any missing variable declarations. Media rebinds to the recipient's library, and their existing variable values are kept.

Sandbox and limits