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
- Your source is the HTML, CSS and TypeScript (or JavaScript) tabs in Studio's code workspace. TypeScript is compiled to JavaScript before anything runs it, and types never block a preview, a save or Publish. It shares the overlay's draft, history, Publish and OBS URL.
- Settings declare what the graphic may use: events, messages, variables, media, fonts, libraries and controls. Anything undeclared never reaches it.
- Flows decide when something happens. A flow sends an event or message; the graphic decides how it looks.
- The clock belongs to Studio. Play, pause and seek drive the graphic, so anything that moves should follow it.
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.
<article id="card">
<span id="caption">READY TO PLAY</span>
<strong id="name">Your next great moment</strong>
</article>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; }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.
- Alerts (
follow,sub,resub,gift,cheer,raid): tick the event under Data & settings → Events. Studio queues a matching flow, or reuses one you have, and publishes it with your draft. The layer's Content tab lists them under Triggered by flows. - Your own messages: declare a name in
messages, such asround-start, and send it from any flow. It arrives as acustomevent.
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.
{ "name": "beanboozled", "fields": { "result": ["safe", "boozled"], "total": "number" } }- Up to 16 fields. Each name matches a Values row name (1–32 letters, digits or underscores).
- A field is
"text","number", or a list of up to 32 allowed values (1–64 characters each). orderkeeps the fields in the order you wrote them; leave it out and the written order is used.- Checks happen while a flow is edited, never at delivery. The flow editor and MCP
validate_flow,create_flowandupdate_flowwarn about a missing or undeclared value, a value outside the list, or text in a number field (overlay_message_missing,overlay_message_extra,overlay_message_not_allowed,overlay_message_not_number). Values containing$(…)are only checked for presence. - Every value still arrives as text in
event.data.
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:
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:
@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
- Your media: declare a file in
assetsand usebloop.assets.name, a local URL. - Pictures from other websites:
await bloop.loadImage(url)fetches anhttps://PNG, JPEG, GIF, WebP or AVIF of up to 8 MB through Bloopbot and returns a local URL. Call it again rather than keeping an old URL; images are cached for a day. - Canvas:
bloop.canvas({ draw })adds a canvas sized for the real output, with its 2D context already scaled, and callsdrawon every tick. - SVG filters work, so gooey, displacement, glow and glitch effects are possible. Define the filter in HTML and apply it with CSS:
<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:
.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:
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
- Asset names start with a lowercase letter and use lowercase letters, digits and underscores, up to 48 characters. Control keys start with a lowercase letter and may use capitals too, such as
durationSec, up to 48 characters.constructor,prototype,__proto__and the other names every JavaScript object has, such astoStringandvalueOf, are refused. - Labels have 1–80 characters. Defaults and values must match their type;
falseand0are kept. - Source is never rewritten: Bloopbot does not expand
$(…)templates or swap asset-like strings in your code. Bind media throughassetsor anassetsetting.
Build and test through MCP
An AI assistant connected through MCP can write and check a graphic without OBS:
describe_layer_kind({ kind: 'graphics' })returns the allowed events and a complete starter.create_overlayacceptsstartWith: { kind: 'graphics' }.save_overlay_draftreplaces the whole source document, checking the revision. A TypeScript graphic that does not compile is still saved;save_overlay_draft,patch_overlay_draftand a source upload link returncompileWarningswith each field, line and message (a layer the server could not check just now, because the channel compiled too often in the last minute or the server was busy, is reported as "Not checked").preview_overlayrenders a moment:graphics: { timeMs, events: [{ timeMs, frame }], variables }, with up to 32 made-up events and 32 values. Passframes(2–12 timestamps) instead oftimeMsfor a strip of images. Events after the chosen time are ignored, and each layer keeps only the types it declares. Withoutevents, it uses the sample for the first declared event. Previews change no live data.
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
- Source runs in an isolated frame. It receives only declared data and media, never the channel's credentials, and cannot read the dashboard, cookies or storage.
- Blocked: remote scripts, images and media,
fetch(exceptblob:URLs, so loaders such asPIXI.Assets.loadcan read your media), sockets, workers and nested frames. Allowed: Google Fonts stylesheets and font files, and pictures throughbloop.loadImage. - Declared media: up to 8 MB per file and 32 MB in total.
- A graphic downloads only the libraries it lists, each a separately cached bundle.
- OBS receives your CSS and JavaScript minified. Studio keeps them as written, so Output line numbers match the editor. Errors and media failures show there too.
- An infinite loop runs in the viewer's browser and can stall the overlay. Keep per-tick work small.
- The frame can still navigate itself, so only run source you trust with channel data.