Skip to content

Graphics libraries

A custom graphic can use four pinned animation and rendering libraries, the free GSAP plugins, and add-ons for PixiJS and Three.js. This page covers each one: what you tick, what your code gets, a working example, and the limits. Every example here runs as written in a custom graphic's JavaScript tab. For the bloop API itself, see Build a custom overlay; for the step-by-step Studio walkthrough, see Author a custom graphic.

Choosing libraries

Tick libraries in the code workspace under Data & settings → Libraries, or list them in the graphic's libraries setting. An add-on or plugin is listed beside its library; on its own it is refused.

Library libraries name Version Your code gets
GSAP gsap 3.15.0 gsap, and bloop.timeline()
GSAP plugins gsap/SplitText, gsap/CustomEase, … (10) 3.15.0 each plugin by its own name
PixiJS pixi 8.22.0 PIXI
PixiJS add-ons pixi/filters, pixi/gif, pixi/blend-modes pixi-filters 6.1.5 filter classes, GifSprite, GifSource, blend modes
Three.js three 0.186.1 THREE
Three.js add-ons three/postprocessing, three/loaders, three/geometries, three/environment 0.186.1 each add-on class by its own name
Phaser phaser 4.2.1 Phaser, with Arcade and Matter physics built in

A graphic downloads only what it lists. Each library, the GSAP plugins and each add-on group is a separate file, cached by the browser, so a GSAP card never loads Three.js and a second graphic using the same library loads nothing new.

File Size (gzip)
The graphic's own runtime 8 KB
GSAP 27 KB
GSAP plugins (all ten) 26 KB
PixiJS (with GIF support) 266 KB
PixiJS filters 41 KB
Three.js 186 KB
Three.js post-processing / loaders / geometries / sky, water and light 15 / 26 / 4 / 8 KB
Phaser 364 KB

OBS receives your graphic's CSS and JavaScript minified. Studio keeps them as you wrote them, so line numbers in Output match the editor.

Follow the graphic's clock

Studio can pause, scrub and replay a graphic, and an MCP preview renders a chosen moment. That only works when everything that moves is driven by the graphic's clock:

function seeded(seed) {
  return () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
}
const random = seeded(42);
console.log('same every run:', random().toFixed(3), random().toFixed(3));

Release what you create in bloop.onDispose(…): Studio rebuilds the graphic on every seek and code change.

GSAP

Tick GSAP. bloop.timeline() returns a paused GSAP timeline attached to the moment it was created; gsap is available for its utilities and eases.

const card = document.createElement('div');
card.style.cssText = 'font:700 48px system-ui;color:#fff;padding:24px;opacity:0';
document.getElementById('graphic').append(card);
bloop.onEvent('follow', (event) => {
  card.textContent = `Welcome, ${event.name ?? 'friend'}`;
  bloop.timeline()
    .fromTo(card, { y: 40, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: 'back.out(2)' })
    .to(card, { opacity: 0, duration: 0.4 }, '+=3');
});

GSAP plugins

With GSAP ticked, GSAP plugins lists the free plugins. Each ticked plugin is registered for you and is a global by its own name.

Tick libraries name Global Use it for
SplitText gsap/SplitText SplitText Animating text by letter, word or line
CustomEase gsap/CustomEase CustomEase Your own easing curves
CustomWiggle gsap/CustomWiggle CustomWiggle Shakes and wobbles
CustomBounce gsap/CustomBounce CustomBounce Bounces with squash
MorphSVG gsap/MorphSVG MorphSVGPlugin Morphing one SVG shape into another
DrawSVG gsap/DrawSVG DrawSVGPlugin Drawing SVG strokes on
MotionPath gsap/MotionPath MotionPathPlugin Moving along an SVG path
Physics2D gsap/Physics2D Physics2DPlugin Thrown and falling particles
ScrambleText gsap/ScrambleText ScrambleTextPlugin Decoding text effects
Text gsap/Text TextPlugin Typing text in

Scroll, drag and developer-tool plugins are not offered: OBS has no input.

SplitText: split a raider's name into letters and drop them in.

const name = document.createElement('h1');
name.style.cssText = 'font:800 64px system-ui;color:#fff;margin:24px';
document.getElementById('graphic').append(name);
bloop.onEvent('raid', (event) => {
  name.textContent = event.name ?? 'Raid party';
  const split = new SplitText(name, { type: 'chars' });
  bloop.timeline().from(split.chars, { y: -60, opacity: 0, rotation: -20, stagger: 0.04, duration: 0.5, ease: 'back.out(3)' });
});

Split text inside the event handler, after the text is set. With the font listed under Fonts, the first event waits for it, so letters are measured in the right font.

CustomEase and CustomWiggle: a named pop and a shake. CustomWiggle and CustomBounce build on CustomEase, which is registered for them; tick CustomEase too to use it yourself.

CustomEase.create('pop', 'M0,0 C0.2,1.4 0.4,1 1,1');
CustomWiggle.create('shake', { wiggles: 8, type: 'easeOut' });
const badge = document.createElement('div');
badge.textContent = 'CHEER!';
badge.style.cssText = 'display:inline-block;font:800 56px system-ui;color:#ffd166;margin:40px';
document.getElementById('graphic').append(badge);
bloop.onEvent('cheer', () => {
  bloop.timeline()
    .fromTo(badge, { scale: 0 }, { scale: 1, duration: 0.5, ease: 'pop' })
    .to(badge, { rotation: 12, duration: 0.8, ease: 'shake' });
});

MorphSVG and DrawSVG: draw a ring on, then morph a triangle into a circle.

document.getElementById('graphic').innerHTML = `
  <svg viewBox="0 0 200 200" width="200" height="200">
    <path id="shape" d="M100 30 L170 170 L30 170 Z" fill="#467bff"/>
    <circle id="ring" cx="100" cy="100" r="90" fill="none" stroke="#7fe0c2" stroke-width="6"/>
  </svg>`;
bloop.timeline()
  .from('#ring', { drawSVG: '0%', duration: 1 })
  .to('#shape', { morphSVG: 'M100 30 C 177 30 177 170 100 170 C 23 170 23 30 100 30 Z', duration: 1 });

MotionPath: send a dot along a curve.

document.getElementById('graphic').innerHTML = `
  <svg viewBox="0 0 400 160" width="400" height="160">
    <path id="track" d="M20 140 C 120 -40 280 -40 380 140" fill="none" stroke="#333" stroke-width="2"/>
    <circle id="dot" r="12" fill="#ffd166"/>
  </svg>`;
bloop.timeline().to('#dot', { duration: 2, ease: 'power1.inOut', motionPath: { path: '#track', align: '#track', alignOrigin: [0.5, 0.5] } });

Physics2D: throw confetti with gravity, the same way on every replay thanks to a seeded generator.

let seed = 7;
const random = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };
bloop.onEvent('cheer', () => {
  const tl = bloop.timeline();
  for (let i = 0; i < 30; i++) {
    const bit = document.createElement('i');
    bit.style.cssText = `position:absolute;left:50%;top:80%;width:10px;height:10px;background:hsl(${random() * 360},90%,60%)`;
    document.getElementById('graphic').append(bit);
    tl.to(bit, { duration: 1.6, physics2D: { velocity: 300 + random() * 250, angle: -90 + (random() - 0.5) * 80, gravity: 700 } }, 0);
  }
  tl.call(() => document.querySelectorAll('#graphic i').forEach((bit) => bit.remove()));
});

ScrambleText and Text: decode a headline, then type the subscriber's name.

document.getElementById('graphic').innerHTML = '<h2 id="title" style="font:800 40px monospace;color:#7fe0c2;margin:24px 24px 0"></h2><p id="who" style="font:600 28px system-ui;color:#fff;margin:8px 24px"></p>';
bloop.onEvent('sub', (event) => {
  bloop.timeline()
    .to('#title', { duration: 1, scrambleText: { text: 'NEW SUBSCRIBER', chars: '01#*' } })
    .to('#who', { duration: 1, text: event.name ?? 'Someone awesome', ease: 'none' });
});

PixiJS

Tick PixiJS. Create the application with autoStart: false, stop its ticker and render from bloop.onTick. Pass resolution: bloop.size.pixelRatio with autoDensity: true so the canvas is sharp at the layer's real size in OBS.

const app = new PIXI.Application();
app.init({ width: bloop.size.width, height: bloop.size.height, backgroundAlpha: 0, autoStart: false, resolution: bloop.size.pixelRatio, autoDensity: true }).then(() => {
  app.ticker.stop();
  document.getElementById('graphic').append(app.canvas);
  const star = new PIXI.Graphics().star(0, 0, 5, 40).fill(0xffd166);
  star.position.set(app.screen.width / 2, app.screen.height / 2);
  app.stage.addChild(star);
  bloop.onTick(({ timeMs }) => { star.rotation = timeMs / 500; app.render(); });
  bloop.onDispose(() => app.destroy(true, { children: true }));
});

app.init is asynchronous. If your graphic handles events, buffer the first one until it resolves.

Pictures from your media library: name a file under Media (here portrait) and make a texture from its image.

const app = new PIXI.Application();
const image = new Image();
image.src = bloop.assets.portrait;
Promise.all([app.init({ width: 300, height: 300, backgroundAlpha: 0, autoStart: false }), image.decode()]).then(() => {
  app.ticker.stop();
  document.getElementById('graphic').append(app.canvas);
  const sprite = new PIXI.Sprite(PIXI.Texture.from(image));
  sprite.anchor.set(0.5); sprite.position.set(150, 150); sprite.width = sprite.height = 200;
  app.stage.addChild(sprite);
  bloop.onTick(({ timeMs }) => { sprite.rotation = Math.sin(timeMs / 600) * 0.1; app.render(); });
  bloop.onDispose(() => app.destroy(true, { children: true }));
});

Filters

Tick PixiJS add-ons → Filters. Every pixi-filters class is a global: AdjustmentFilter, AdvancedBloomFilter, AsciiFilter, BackdropBlurFilter, BevelFilter, BloomFilter, BulgePinchFilter, CRTFilter, ColorGradientFilter, ColorMapFilter, ColorOverlayFilter, ColorReplaceFilter, ConvolutionFilter, CrossHatchFilter, DotFilter, DropShadowFilter, EmbossFilter, GlitchFilter, GlowFilter, GodrayFilter, GrayscaleFilter, HslAdjustmentFilter, KawaseBlurFilter, MotionBlurFilter, MultiColorReplaceFilter, OldFilmFilter, OutlineFilter, PixelateFilter, RGBSplitFilter, RadialBlurFilter, ReflectionFilter, ShockwaveFilter, SimpleLightmapFilter, SimplexNoiseFilter, TiltShiftAxisFilter, TiltShiftFilter, TwistFilter and ZoomBlurFilter.

Drive any animated property (a shockwave's time, a glitch's seed) from bloop.onTick so it follows the clock:

const app = new PIXI.Application();
app.init({ width: 400, height: 240, backgroundAlpha: 0, autoStart: false }).then(() => {
  app.ticker.stop();
  document.getElementById('graphic').append(app.canvas);
  const orb = new PIXI.Graphics().circle(200, 120, 50).fill(0x467bff);
  const glow = new GlowFilter({ distance: 20, outerStrength: 2, color: 0x7fe0c2 });
  const wave = new ShockwaveFilter({ center: { x: 200, y: 120 }, amplitude: 25, wavelength: 140, speed: 400, radius: -1 });
  orb.filters = [glow, wave];
  app.stage.addChild(orb);
  let hitAt = -Infinity;
  bloop.onEvent('raid', () => { hitAt = bloop.timeMs; });
  bloop.onTick(({ timeMs }) => {
    glow.outerStrength = 2 + Math.sin(timeMs / 250);
    wave.time = Math.min((timeMs - hitAt) / 1000, 2);
    app.render();
  });
  bloop.onDispose(() => app.destroy(true, { children: true }));
});

Animated GIFs

Tick PixiJS add-ons → Animated GIFs for GifSprite and GifSource. Read the GIF's bytes from your media library, then set the frame from the clock so pausing and seeking show the right frame:

const app = new PIXI.Application();
Promise.all([app.init({ width: 300, height: 300, backgroundAlpha: 0, autoStart: false }), fetch(bloop.assets.party).then((r) => r.arrayBuffer())]).then(([, bytes]) => {
  app.ticker.stop();
  document.getElementById('graphic').append(app.canvas);
  const source = GifSource.from(bytes);
  const gif = new GifSprite({ source, autoPlay: false });
  app.stage.addChild(gif);
  bloop.onTick(({ timeMs }) => {
    gif.currentFrame = Math.floor((timeMs % source.duration) / source.duration * source.totalFrames);
    app.render();
  });
  bloop.onDispose(() => app.destroy(true, { children: true }));
});

Advanced blend modes

Tick PixiJS add-ons → Advanced blend modes to use color, color-burn, color-dodge, darken, difference, divide, exclusion, hard-light, hard-mix, lighten, linear-burn, linear-dodge, linear-light, luminosity, negation, overlay, pin-light, saturation, soft-light, subtract and vivid-light. They need useBackBuffer: true:

const app = new PIXI.Application();
app.init({ width: 300, height: 200, backgroundAlpha: 0, autoStart: false, useBackBuffer: true }).then(() => {
  app.ticker.stop();
  document.getElementById('graphic').append(app.canvas);
  app.stage.addChild(new PIXI.Graphics().rect(0, 0, 300, 200).fill(0x467bff));
  const light = new PIXI.Graphics().circle(150, 100, 70).fill(0xffd166);
  light.blendMode = 'color-dodge';
  app.stage.addChild(light);
  bloop.onTick(({ timeMs }) => { light.x = Math.sin(timeMs / 600) * 60; app.render(); });
  bloop.onDispose(() => app.destroy(true, { children: true }));
});

Three.js

Tick Three.js. Render from bloop.onTick, follow bloop.onResize and dispose the renderer in bloop.onDispose.

const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setPixelRatio(bloop.size.pixelRatio);
renderer.setSize(bloop.size.width, bloop.size.height);
document.getElementById('graphic').append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, bloop.size.width / bloop.size.height, 0.1, 100);
camera.position.z = 4;
const gem = new THREE.Mesh(new THREE.IcosahedronGeometry(1), new THREE.MeshNormalMaterial({ flatShading: true }));
scene.add(gem);
bloop.onResize(({ width, height, pixelRatio }) => {
  renderer.setPixelRatio(pixelRatio); renderer.setSize(width, height);
  camera.aspect = width / height; camera.updateProjectionMatrix();
});
bloop.onTick(({ timeMs }) => { gem.rotation.set(timeMs / 1400, timeMs / 900, 0); renderer.render(scene, camera); });
bloop.onDispose(() => renderer.dispose());

Pictures from your media library load with new THREE.TextureLoader().load(bloop.assets.name).

Post-processing

Tick Three.js add-ons → Post-processing for EffectComposer, RenderPass, ShaderPass, OutputPass, UnrealBloomPass, GlitchPass, AfterimagePass, FilmPass, BokehPass, OutlinePass, RenderPixelatedPass, HalftonePass, DotScreenPass and FXAAPass. Render the composer instead of the renderer:

const renderer = new THREE.WebGLRenderer({ alpha: true });
renderer.setPixelRatio(bloop.size.pixelRatio);
renderer.setSize(bloop.size.width, bloop.size.height);
document.getElementById('graphic').append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, bloop.size.width / bloop.size.height, 0.1, 100);
camera.position.z = 5;
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.2, 0.25, 24, 96), new THREE.MeshBasicMaterial({ color: 0x7fe0c2 }));
scene.add(ring);
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(bloop.size.width, bloop.size.height), 1.4, 0.5, 0.1));
composer.addPass(new OutputPass());
bloop.onTick(({ timeMs }) => { ring.rotation.x = timeMs / 1000; composer.render(); });
bloop.onDispose(() => { composer.dispose(); renderer.dispose(); });

GlitchPass and FilmPass draw random noise of their own, so a scrubbed preview shows a different glitch each time.

Loaders

Tick Three.js add-ons → Loaders for GLTFLoader, FontLoader, SVGLoader, OBJLoader and MTLLoader. Your media library holds pictures, sounds and videos, not 3D models or font files, so use each loader's parse() on data in your code. SVGLoader turns SVG markup into shapes you can extrude, which is the usual way to put a logo in 3D:

const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(300, 300);
document.getElementById('graphic').append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 1, 1000);
camera.position.z = 220;
const heart = '<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 -30 C 0 -60 -60 -60 -60 -20 C -60 20 0 40 0 60 C 0 40 60 20 60 -20 C 60 -60 0 -60 0 -30 Z"/></svg>';
const group = new THREE.Group();
for (const path of new SVGLoader().parse(heart).paths) {
  for (const shape of SVGLoader.createShapes(path)) {
    group.add(new THREE.Mesh(new THREE.ExtrudeGeometry(shape, { depth: 20, bevelEnabled: true, bevelSize: 3, bevelThickness: 3 }), new THREE.MeshNormalMaterial()));
  }
}
group.scale.y = -1;
scene.add(group);
bloop.onTick(({ timeMs }) => { group.rotation.y = timeMs / 800; renderer.render(scene, camera); });
bloop.onDispose(() => renderer.dispose());

GLTFLoader().parse(arrayBuffer, '', onLoad) and OBJLoader().parse(text) work the same way for a small model you embed. DRACO and KTX2 compressed models, TTFLoader and anything fetched from another website are not available: the sandbox has no network.

Geometries

Tick Three.js add-ons → Geometries for RoundedBoxGeometry, ParametricGeometry, ConvexGeometry and TextGeometry.

const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(300, 300);
document.getElementById('graphic').append(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.z = 4;
const box = new THREE.Mesh(new RoundedBoxGeometry(1.6, 1.6, 1.6, 6, 0.3), new THREE.MeshNormalMaterial());
scene.add(box);
bloop.onTick(({ timeMs }) => { box.rotation.set(timeMs / 1200, timeMs / 1500, 0); renderer.render(scene, camera); });
bloop.onDispose(() => renderer.dispose());

TextGeometry needs a typeface font: tick Loaders too and pass new FontLoader().parse(typefaceJson) a typeface JSON object in your code (convert a font with facetype.js). Fonts cannot come from the media library yet.

Sky, water and light

Tick Three.js add-ons → Sky, water and light for Sky, Water, Lensflare, LensflareElement and RoomEnvironment. RoomEnvironment gives physically based materials soft studio lighting with no textures:

const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
renderer.setSize(300, 300);
document.getElementById('graphic').append(renderer.domElement);
const scene = new THREE.Scene();
const environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
scene.environment = environment;
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.z = 4;
const trophy = new THREE.Mesh(new THREE.TorusKnotGeometry(0.8, 0.28, 160, 24), new THREE.MeshStandardMaterial({ color: 0xffd166, metalness: 1, roughness: 0.2 }));
scene.add(trophy);
bloop.onTick(({ timeMs }) => { trophy.rotation.y = timeMs / 1000; renderer.render(scene, camera); });
bloop.onDispose(() => { environment.dispose(); renderer.dispose(); });

Water and Lensflare take textures: load them from your media library with THREE.TextureLoader.

Phaser

Tick Phaser. Arcade and Matter physics, tweens, particles and cameras are built in; there are no add-ons. Stop Phaser's loop once the game is ready and step it from the clock. Set hideBanner: true to keep its startup banner out of Output.

const game = new Phaser.Game({
  type: Phaser.CANVAS, width: 400, height: 240, transparent: true, hideBanner: true, parent: 'graphic',
  physics: { default: 'arcade', arcade: { gravity: { x: 0, y: 500 } } },
  scene: { create() {
    const ball = this.add.circle(200, 40, 20, 0x467bff);
    this.physics.add.existing(ball);
    ball.body.setBounce(0.8).setCollideWorldBounds(true);
  } },
});
game.events.once('ready', () => game.loop.stop());
bloop.onTick(({ timeMs, deltaMs }) => { if (!game.loop.running && deltaMs > 0) game.step(timeMs, deltaMs); });
bloop.onDispose(() => game.destroy(true));

Physics steps with the clock's delta, so a scrubbed moment can land slightly differently from live playback.

Limits

Troubleshooting