Build your own widgets
Jlerts has a lot built in, but a custom widget can be anything a web page can be. In these two tutorials you'll build widgets the app's own editors can't make: a spooky chat box with chibi badges and messages that take turns left and right, and a slime goal that wobbles, fills with fizzing green goo and turns greener as it fills. Both are free to download, use and change.
You'll need Jlerts, a text editor (Notepad works; VS Code is nicer), and for drawing your own art, Inkscape (free) or any program that saves SVG. No coding experience? Download the finished widgets and change the colours in the app; then read along to see how each part works.
How custom widgets work
A custom widget is a folder with a small web page in it:
| File | What it's for |
|---|---|
index.html | The page. The app shows it on your overlay at the widget's size, see-through wherever you don't draw. |
style.css | The look: colours, shapes, fonts, animations. |
script.js | What it does: reacts to chat and events, and draws. |
fields.json | Its settings. The app turns this list into a normal settings panel, so anyone can change colours and options without touching code. |
art/… | Pictures, sounds, fonts: anything else the page uses. |
The page runs sealed off: it can't touch the app, your other widgets or your logins. It hears what happens
through one script the app provides, /custom-api.js, which gives your code a widget object:
| Code | What it does |
|---|---|
widget.on("settings", fn) | Your fields.json values, when the page starts and whenever they change in the app. |
widget.on("chat", fn) | Every chat message: user, login, text, color, badges, roles, fragments (text and emotes). |
widget.on("event", fn) | Every event: { kind, vars, amount, test }. Kinds include follow, sub, resub, giftsub, cheer, raid, donation, redeem. Or listen to one: widget.on("follow", fn). |
widget.store.get(key) / .set(key, value) | Small things saved between streams (a count, a list of names). |
widget.settings | The current settings, any time. |
Add one to the app
- Unzip the widget's folder somewhere it can stay (for example
Documents\Jlerts widgets\). - In Jlerts: Widgets → + New widget → Custom widget (HTML / CSS / JS).
- In its settings, Widget folder → Choose folder… and pick the folder. Its own settings (from
fields.json) appear underneath. (Starting from nothing? Make a starter widget writes a small working example into an empty folder.) - Look sets its size on the overlay. Then drag it onto your overlay like any widget.
Changed a file? Refresh the overlay to see it: in OBS, right-click the overlay source → Refresh. Changed fields.json? Click Reload fields in the widget's settings too.
1. Spooky chat box
Chat as name plates over bubbles, taking turns left and right. Everyone gets a little skull or ghost by their name, and there are chibi badges for VIPs, tier 1, 2 and 3 subs, mods (a Frankenstein's monster) and you, plus cobwebs and ghosts drifting up behind the chat.
Step 1: Draw the badges
The art is SVG: drawings described as shapes, so they stay sharp at any size and are tiny files. You can draw them in Inkscape (save as Plain SVG), but they're also just text you can read and change. Here's the skull every sub badge is built on:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- A chibi skull: a big round head, a small jaw, huge eyes. Thick outline so it reads at badge size. -->
<path d="M32 5C18 5 8 14 8 27c0 7 3 12 8 15v8c0 3.3 2.7 6 6 6h20c3.3 0 6-2.7 6-6v-8c5-3 8-8 8-15C56 14 46 5 32 5z"
fill="#f7f2ff" stroke="#2b1d3a" stroke-width="4" stroke-linejoin="round"/>
<ellipse cx="22.5" cy="28" rx="6.5" ry="7.5" fill="#2b1d3a"/>
<ellipse cx="41.5" cy="28" rx="6.5" ry="7.5" fill="#2b1d3a"/>
<circle cx="20.5" cy="25.5" r="2.4" fill="#fff"/>
<circle cx="39.5" cy="25.5" r="2.4" fill="#fff"/>
<ellipse cx="15" cy="37" rx="4" ry="2.4" fill="#ff9fcf" opacity=".8"/>
<ellipse cx="49" cy="37" rx="4" ry="2.4" fill="#ff9fcf" opacity=".8"/>
<path d="M29.5 39.5l2.5-4 2.5 4z" fill="#2b1d3a" stroke="#2b1d3a" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M26 46v7M32 46v7M38 46v7" stroke="#2b1d3a" stroke-width="3" stroke-linecap="round"/>
</svg>viewBox="0 0 64 64"is the drawing's own grid: 64 by 64 units, scaled to whatever size the badge is shown at.- The path is the head and jaw in one shape.
Cdraws curves,v/hstraight lines,zcloses it. - Ellipses make the big chibi eyes; small white circles are the shine that makes them look alive.
- One thick dark
strokeround everything: at badge size a bold outline is what keeps it readable.
Each badge is that skull (or a ghost) plus an accessory: bat wings for tier 2, a crown for tier 3, a gem for VIPs, a top hat and microphone for you, and a number in a coloured circle so tiers are easy to tell apart. The mod badge is its own drawing:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Mod: a chibi Frankenstein's monster. A soft square head, jagged hair, neck bolts and a stitched scar. -->
<!-- Neck bolts (drawn first so the head sits over them) -->
<rect x="3" y="40" width="10" height="8" rx="2" fill="#b9c2cf" stroke="#2b1d3a" stroke-width="3"/>
<rect x="51" y="40" width="10" height="8" rx="2" fill="#b9c2cf" stroke="#2b1d3a" stroke-width="3"/>
<!-- Head -->
<rect x="10" y="10" width="44" height="48" rx="11" fill="#9fe39a" stroke="#2b1d3a" stroke-width="4"/>
<!-- Hair: a flat top with a jagged fringe -->
<path d="M10 22V19c0-5 4-9 9-9h26c5 0 9 4 9 9v3l-5 6-4-5-5 6-4-6-5 6-4-6-5 6-4-6-4 5z"
fill="#2b1d3a" stroke="#2b1d3a" stroke-width="2" stroke-linejoin="round"/>
<!-- Stitched scar across the forehead -->
<path d="M36 31h12" stroke="#2b1d3a" stroke-width="2.2" stroke-linecap="round"/>
<path d="M39 28.5v5M42.5 28.5v5M46 28.5v5" stroke="#2b1d3a" stroke-width="1.8" stroke-linecap="round"/>
<!-- Sleepy eyes: big, with heavy lids -->
<ellipse cx="23" cy="39" rx="5" ry="5.6" fill="#2b1d3a"/>
<ellipse cx="41" cy="39" rx="5" ry="5.6" fill="#2b1d3a"/>
<circle cx="21.6" cy="38" r="1.8" fill="#fff"/>
<circle cx="39.6" cy="38" r="1.8" fill="#fff"/>
<path d="M17.5 36h11M35.5 36h11" stroke="#9fe39a" stroke-width="3.2" stroke-linecap="round"/>
<path d="M17.5 35.2h11M35.5 35.2h11" stroke="#2b1d3a" stroke-width="2" stroke-linecap="round"/>
<!-- Blush and a little stitched smile -->
<ellipse cx="16" cy="47" rx="3.4" ry="2" fill="#ff9fcf" opacity=".8"/>
<ellipse cx="48" cy="47" rx="3.4" ry="2" fill="#ff9fcf" opacity=".8"/>
<path d="M26 50q6 3 12 0" fill="none" stroke="#2b1d3a" stroke-width="2.6" stroke-linecap="round"/>
<path d="M29 49v3M35 49v3" stroke="#2b1d3a" stroke-width="1.6" stroke-linecap="round"/>
</svg>Tip: draw things in the order they stack. The neck bolts come first so the head covers their inner ends, and the scar and eyes go on top.
Step 2: Its settings (fields.json)
Every setting you want people to change without code goes in fields.json: a list, each with a
key (the name your code uses), a label (what the app shows) and a type:
text, number, colour, font, checkbox, select
(with options) or textarea. The default is used until someone changes it.
[
{ "key": "side", "label": "Messages sit on", "type": "select", "default": "Alternate sides", "options": ["Alternate sides", "Left", "Right"] },
{ "key": "max", "label": "Messages on screen", "type": "number", "default": 7, "min": 1, "max": 30 },
{ "key": "fadeAfter", "label": "Fade out after (seconds, 0 = never)", "type": "number", "default": 0, "min": 0, "max": 600 },
{ "key": "nameFont", "label": "Name font", "type": "font", "default": "Arial Black" },
{ "key": "font", "label": "Message font", "type": "font", "default": "Segoe UI" },
{ "key": "size", "label": "Text size", "type": "number", "default": 18, "min": 10, "max": 48 },
{ "key": "nameColours", "label": "Name colours", "type": "select", "default": "Their Twitch colour", "options": ["Their Twitch colour", "Theme colour"] },
{ "key": "themeName", "label": "Theme name colour", "type": "colour", "default": "#6b3fc4" },
{ "key": "plate", "label": "Name plate colour", "type": "colour", "default": "#efe6ff" },
{ "key": "bubble", "label": "Message bubble colour", "type": "colour", "default": "#ffffff" },
{ "key": "text", "label": "Message text colour", "type": "colour", "default": "#3b2a55" },
{ "key": "outline", "label": "Outline colour", "type": "colour", "default": "#2b1d3a" },
{ "key": "faces", "label": "A little skull or ghost by each name", "type": "checkbox", "default": true },
{ "key": "cobwebs", "label": "Cobwebs on the bubbles", "type": "checkbox", "default": true },
{ "key": "ghosts", "label": "Ghosts drift up behind the chat", "type": "checkbox", "default": true },
{ "key": "hideCommands", "label": "Hide !commands", "type": "checkbox", "default": true },
{ "key": "ignore", "label": "Never show (bots, comma separated)", "type": "text", "default": "nightbot, streamelements, streamlabs, moobot, fossabot" }
]Step 3: The page
The page is almost empty: a box for the drifting ghosts and one for the chat. The script fills it in. Note the order at the bottom: /custom-api.js first (it makes widget), then your script.
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="ghosts"></div>
<div id="chat"></div>
<script src="/custom-api.js"></script>
<script src="script.js"></script>
</body>
</html>Step 4: The look (style.css)
CSS variables carry the settings into the styles. The script sets --plate, --outline and
the rest from fields.json, and every rule uses them, so changing a colour in the app changes the whole look:
:root {
--plate: #efe6ff;
--bubble: #ffffff;
--text: #3b2a55;
--outline: #2b1d3a;
--size: 18px;
--font: "Segoe UI", sans-serif;
--name-font: "Arial Black", sans-serif;
}The chat is a column that stacks from the bottom, so the newest message is always at the bottom and old ones get pushed up:
#chat {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: calc(var(--size) * 0.55);
padding: 14px 16px 18px;
}
The name row sits a little over the bubble's top edge (a negative bottom margin) and above it (z-index),
like a tag on a parcel. For messages on the right, row-reverse mirrors the row, so badges end up on the outside:
/* The name row: name plate, the little face, the badges. On the right it's mirrored (row-reverse). */
.head {
display: flex;
align-items: center;
gap: 6px;
padding: 0 12px;
/* Overlaps the bubble's top edge a little, like a tag on a parcel. */
margin-bottom: calc(var(--size) * -0.35);
position: relative;
z-index: 1;
}
.right .head {
flex-direction: row-reverse;
}The name plate: a pill shape (border-radius: 999px), the chunky outline, and a hard shadow straight down for a sticker look. Long names end in "…" instead of spilling out:
.plate {
background: var(--plate);
border: 3px solid var(--outline);
border-radius: 999px;
padding: 1px 16px 2px;
font: 900 calc(var(--size) * 0.82) / 1.35 var(--name-font);
letter-spacing: 0.05em;
text-transform: uppercase;
white-space: nowrap;
/* A chunky "sticker" edge: a hard shadow straight down. */
box-shadow: 0 3px 0 var(--outline);
max-width: 70vw;
overflow: hidden;
text-overflow: ellipsis;
}The cobweb is a pseudo-element (::after): an extra box CSS adds to every bubble, so the script never has to. The bubble gets a little extra padding on that side so the web never covers words:
/* A cobweb in the top corner away from the name (switched off with the "Cobwebs" setting). The bubble gets a little
more room on that side so the web never sits over the text. */
.cobwebs .bubble {
padding-right: calc(var(--size) * 1.45);
}
.cobwebs .right .bubble {
padding-right: calc(var(--size) * 0.95);
padding-left: calc(var(--size) * 1.45);
}
.cobwebs .bubble::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: calc(var(--size) * 1.6);
height: calc(var(--size) * 1.6);
background: url("art/web.svg") no-repeat top right / contain;
border-top-right-radius: 15px;
pointer-events: none;
}
The pop-in is a keyframe animation. The cubic-bezier(0.2, 1.5, 0.4, 1) timing goes past 1, so the
message overshoots its size and settles back: that's the bounce.
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.55) translateY(14px);
}
to {
opacity: 1;
transform: none;
}
}Step 5: The messages (script.js)
The settings' defaults are written into the script too (the same as fields.json). The widget draws
straight away with them, and they cover any setting that's missing, so it never starts with everything switched off:
// The settings' defaults (the same as fields.json): used until the real settings arrive, and for any that are
// missing, so the widget never starts with everything switched off.
const DEFAULTS = {
side: "Alternate sides",
max: 7,
fadeAfter: 0,
nameFont: "Arial Black",
font: "Segoe UI",
size: 18,
nameColours: "Their Twitch colour",
themeName: "#6b3fc4",
plate: "#efe6ff",
bubble: "#ffffff",
text: "#3b2a55",
outline: "#2b1d3a",
faces: true,
cobwebs: true,
ghosts: true,
hideCommands: true,
ignore: "nightbot, streamelements, streamlabs, moobot, fossabot",
};
let s = { ...DEFAULTS }; // the settings (fields.json)When the settings arrive (and whenever they change), they go on top of the defaults and become the CSS variables from Step 4:
function applySettings(settings) {
s = { ...DEFAULTS, ...settings };
const root = document.documentElement.style;
root.setProperty("--plate", s.plate);
root.setProperty("--bubble", s.bubble);
root.setProperty("--text", s.text);
root.setProperty("--outline", s.outline);
root.setProperty("--size", `${Number(s.size) || 18}px`);
root.setProperty("--font", `"${s.font}", "Segoe UI", sans-serif`);
root.setProperty("--name-font", `"${s.nameFont}", "Arial Black", sans-serif`);
document.body.classList.toggle("cobwebs", !!s.cobwebs);
trim();
}
// When the settings arrive, and whenever you change them in the app.
widget.on("settings", applySettings);
// Draw it straight away (with the settings it already has, else the defaults); the real ones follow.
applySettings(widget.settings);
Badges. Twitch sends badges like "subscriber/3012": the set, then a version. For subs, the version
says the tier: 3000 and up is tier 3, 2000 and up tier 2, anything else tier 1. Each badge is just an
<img> pointing at one of your SVGs:
// Twitch badges look like "subscriber/3012": the set, then a version. For subs the version says the tier: 3000 and up
// is tier 3, 2000 and up tier 2, anything else tier 1 (the rest of the number is the months).
function badgesFor(msg) {
const out = [];
const sets = new Map((msg.badges || []).map((b) => b.split("/")));
if (sets.has("broadcaster")) out.push(["broadcaster", "Broadcaster"]);
if (sets.has("moderator") || msg.roles?.mod) out.push(["mod", "Mod"]);
if (sets.has("vip") || msg.roles?.vip) out.push(["vip", "VIP"]);
const sub = sets.get("subscriber") ?? sets.get("founder");
if (sub !== undefined || msg.roles?.sub) {
const v = Number(sub) || 0;
const tier = v >= 3000 ? 3 : v >= 2000 ? 2 : 1;
out.push([`tier${tier}`, `Tier ${tier} sub`]);
}
return out.map(([file, title]) => {
const img = document.createElement("img");
img.src = `art/${file}.svg`;
img.alt = img.title = title;
return img;
});
}The face by each name. To give each viewer the same face every time, it turns their name into a number and picks skull or ghost from that:
// The same viewer always gets the same face: a number made from their name picks skull or ghost.
function faceFor(login) {
let h = 0;
for (const ch of login) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const img = document.createElement("img");
img.className = "face";
img.src = h % 2 ? "art/skull.svg" : "art/ghost.svg";
img.alt = "";
return img;
}
The text, with emotes. Messages arrive in pieces (fragments): text, and emotes with a picture.
Always put viewers' text in with textContent or text nodes, never as HTML, so nobody can put their own
code on your overlay:
// The message arrives in pieces ("fragments"): text, emotes (with a picture), @mentions. textContent keeps viewers'
// text as plain text, so nobody can put their own HTML into your overlay.
function textFor(msg) {
const bubble = document.createElement("div");
bubble.className = "bubble";
const parts = msg.fragments?.length ? msg.fragments : [{ t: "text", text: msg.text }];
for (const f of parts) {
if (f.t === "emote" && f.url) {
const img = document.createElement("img");
img.className = "emote";
img.src = f.url;
img.alt = f.text;
bubble.append(img);
} else {
bubble.append(document.createTextNode(f.text));
}
}
return bubble;
}Building a message. It skips bots and !commands, picks a side (taking turns, or always the same one), builds the name row and bubble, and adds it to the chat:
function show(msg) {
const login = (msg.login || msg.user || "").toLowerCase();
const ignore = String(s.ignore || "").toLowerCase().split(",").map((n) => n.trim()).filter(Boolean);
if (ignore.includes(login)) return;
if (s.hideCommands && (msg.text || "").trim().startsWith("!")) return;
const side = s.side === "Left" ? "left" : s.side === "Right" ? "right" : count % 2 ? "right" : "left";
count++;
const el = document.createElement("div");
el.className = `msg ${side}`;
const head = document.createElement("div");
head.className = "head";
const plate = document.createElement("span");
plate.className = "plate";
plate.textContent = msg.user;
plate.style.color = s.nameColours === "Theme colour" || !msg.color ? s.themeName : msg.color;
const badges = document.createElement("span");
badges.className = "badges";
badges.append(...badgesFor(msg));
head.append(plate);
if (s.faces) head.append(faceFor(login));
head.append(badges);
el.append(head, textFor(msg));
chat.append(el);
trim();
// Fade out after a while (0 = stay until pushed out).
const after = Number(s.fadeAfter) || 0;
if (after > 0) setTimeout(() => leave(el), after * 1000);
}Leaving. Old messages don't just vanish: they get a leaving class (the fade-out animation) and are removed when it ends:
// Too many on screen: the oldest leave.
function trim() {
const shown = [...chat.querySelectorAll(".msg:not(.leaving)")];
const max = Math.max(1, Number(s.max) || 7);
shown.slice(0, Math.max(0, shown.length - max)).forEach(leave);
}
function leave(el) {
if (el.classList.contains("leaving")) return;
el.classList.add("leaving");
el.addEventListener("animationend", () => el.remove(), { once: true });
}Ghosts in the background. Every few seconds a ghost image is added with a random size, place and speed. CSS floats it up, and it removes itself at the top:
function drift() {
if (s.ghosts) {
const g = document.createElement("img");
g.className = "drift";
g.src = "art/ghost.svg";
const size = 26 + Math.random() * 30;
g.style.width = `${size}px`;
g.style.left = `${Math.random() * 90}%`;
g.style.animationDuration = `${9 + Math.random() * 7}s`;
g.addEventListener("animationend", () => g.remove());
ghosts.append(g);
}
setTimeout(drift, 2500 + Math.random() * 3500);
}
drift();Make it yours: draw your own badges (keep the file names, or change them in badgesFor), swap the faces for your mascot, change the bubble shape in .bubble, or add a badge for founders or artists the same way.
2. Slime goal
A goal for anything (follows, subs, bits, tips, raiders, channel points, or every event), shown as a chibi slime filling with goo. It wobbles, the goo's surface rolls, bubbles rise inside, fizz floats up off the surface and pops, and the slime gets greener as it fills. At 100% it closes its eyes happily. It saves its count between streams, and you and your mods can change it from chat.
Step 1: Draw the slime
The slime is one SVG, right in index.html. The trick is that its outline is drawn once
(id="body") and reused three times with <use>: as the jelly, as a clip
(clipPath) that keeps the goo inside, and as the outline on top. Change that one path and the whole slime
changes shape.
<svg id="slime" viewBox="0 0 400 420" aria-hidden="true">
<defs>
<!-- The slime's outline, used three times: its jelly, the clip that keeps the goo inside, and the outline. -->
<path id="body" d="M200 22c6 36 52 64 96 108 50 50 68 106 56 168-10 54-62 92-152 92s-142-38-152-92C36 236 54 180 104 130c44-44 90-72 96-108z" />
<clipPath id="inside"><use href="#body" /></clipPath>
<!-- The goo: lighter at its surface, deeper below. script.js sets both colours. -->
<linearGradient id="goo-colour" x1="0" y1="0" x2="0" y2="1">
<stop id="goo-top" offset="0" stop-color="#9be86a" />
<stop id="goo-deep" offset="1" stop-color="#139a3f" />
</linearGradient>
<!-- A soft glow around the slime, stronger as it fills. -->
<filter id="glow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow id="glow-shadow" dx="0" dy="0" stdDeviation="10" flood-color="#2fe36a" flood-opacity="0" />
</filter>
</defs>
<!-- Shadow on the floor (it squashes with the slime). -->
<ellipse id="shadow" cx="200" cy="402" rx="140" ry="12" fill="#000" opacity=".22" />
<!-- Everything that wobbles together. -->
<g id="creature" filter="url(#glow)">
<use id="jelly" href="#body" fill="#e9f4ec" />
<g clip-path="url(#inside)">
<path id="goo-back" fill="#0e8a37" opacity=".55" />
<path id="goo" fill="url(#goo-colour)" />
<path id="goo-shine" fill="none" stroke="#fff" stroke-width="5" stroke-linecap="round" opacity=".45" />
<g id="bubbles"></g>
</g>
<use href="#body" fill="none" class="outline" stroke-width="9" stroke-linejoin="round" />
<!-- A shine on the jelly, top left. -->
<path d="M128 168c10-22 28-40 46-52" fill="none" stroke="#fff" stroke-width="12" stroke-linecap="round" opacity=".65" />
<circle cx="116" cy="196" r="6" fill="#fff" opacity=".65" />
<!-- The face -->
<g id="face">
<g id="eyes">
<ellipse cx="158" cy="262" rx="17" ry="22" class="ink" />
<ellipse cx="242" cy="262" rx="17" ry="22" class="ink" />
<circle cx="151" cy="253" r="7" fill="#fff" />
<circle cx="235" cy="253" r="7" fill="#fff" />
<circle cx="163" cy="272" r="3.5" fill="#fff" />
<circle cx="247" cy="272" r="3.5" fill="#fff" />
</g>
<g id="happy-eyes" visibility="hidden">
<path d="M140 266q18-24 36 0M224 266q18-24 36 0" fill="none" class="ink-line" stroke-width="8" stroke-linecap="round" />
</g>
<ellipse cx="124" cy="296" rx="16" ry="8" fill="#ff8fb8" opacity=".7" />
<ellipse cx="276" cy="296" rx="16" ry="8" fill="#ff8fb8" opacity=".7" />
<path id="mouth" d="M186 292q14 14 28 0" fill="none" class="ink-line" stroke-width="7" stroke-linecap="round" />
</g>
<!-- Fizz: little bubbles that float up off the goo's surface and pop. Not clipped, so they can rise above it. -->
<g id="fizz"></g>
</g>
</svg>- The goo (
#goo) is a path the script redraws every frame. Its colour is a gradient: lighter at the surface, deeper below. #goo-backis a second, darker wave just behind it, so the surface looks deep.- The glow is an SVG filter (
feDropShadow) whose strength the script raises as it fills. - The face sits on top of the goo.
#happy-eyesis hidden until the goal's reached. #bubblesand#fizzstart empty: the script adds and removes circles in them.
Step 2: Its settings (fields.json)
A select chooses what fills it; the goal, starting point and both slime colours are settings too:
[
{ "key": "title", "label": "Title", "type": "text", "default": "Slime goal" },
{ "key": "counts", "label": "What fills it", "type": "select", "default": "Followers", "options": ["Followers", "Subs (each gifted sub counts)", "Bits", "Tips", "Raiders", "Channel point redeems", "Everything (1 per event)"] },
{ "key": "target", "label": "Goal", "type": "number", "default": 50, "min": 1, "max": 1000000 },
{ "key": "start", "label": "Start from", "type": "number", "default": 0, "min": 0, "max": 1000000 },
{ "key": "countTests", "label": "Count Test events too (handy while setting up)", "type": "checkbox", "default": true },
{ "key": "commands", "label": "Chat commands for you and mods (!slime add 5, !slime set 20, !slime reset)", "type": "checkbox", "default": true },
{ "key": "emptyColour", "label": "Slime colour when nearly empty", "type": "colour", "default": "#c8f59a" },
{ "key": "fullColour", "label": "Slime colour when full", "type": "colour", "default": "#13c24f" },
{ "key": "seeThrough", "label": "See-through like a liquid until it's full", "type": "checkbox", "default": true },
{ "key": "outline", "label": "Outline colour", "type": "colour", "default": "#1d3a28" },
{ "key": "showText", "label": "Show the title and numbers", "type": "checkbox", "default": true },
{ "key": "font", "label": "Font", "type": "font", "default": "Arial Black" },
{ "key": "textColour", "label": "Text colour", "type": "colour", "default": "#ffffff" }
]Step 3: Counting
The app tells every widget about every event; the widget decides what counts. A follow adds 1, a cheer adds its bits,
a tip its amount, a raid its viewers. Test events (from the app's Test buttons) carry test: true, so the
widget can leave them out once you're live:
// How much an event adds, by what the goal counts. The app sends each event's kind and its variables.
function amountFor(ev) {
const v = ev.vars || {};
const n = (x) => Number(String(x ?? "").replace(/[^0-9.]/g, "")) || 0;
const amount = n(ev.amount) || n(v.amount);
switch (s.counts) {
case "Followers":
return ev.kind === "follow" ? 1 : 0;
case "Subs (each gifted sub counts)":
if (ev.kind === "sub" || ev.kind === "resub") return 1;
return ev.kind === "giftsub" ? n(v.count) || amount || 1 : 0;
case "Bits":
return ev.kind === "cheer" ? amount || n(v.bits) : 0;
case "Tips":
return ev.kind === "donation" ? amount : 0;
case "Raiders":
return ev.kind === "raid" ? n(v.viewers) || amount : 0;
case "Channel point redeems":
return ev.kind === "redeem" ? 1 : 0;
default: // Everything (1 per event)
return ["follow", "sub", "resub", "giftsub", "cheer", "raid", "donation", "redeem"].includes(ev.kind) ? 1 : 0;
}
}widget.on("event", (ev) => {
if (ev.test && !s.countTests) return;
const add = amountFor(ev);
if (add > 0) setTotal(total() + add);
});
// Chat commands for you and your mods: !slime add 5, !slime set 20, !slime reset.
widget.on("chat", (msg) => {
if (!s.commands) return;
const allowed = msg.roles?.broadcaster || msg.roles?.mod || (msg.badges || []).some((b) => /^(broadcaster|moderator)\//.test(b));
const m = /^!slime\s+(add|set|reset)\s*(-?[\d.]+)?/i.exec((msg.text || "").trim());
if (!allowed || !m) return;
const number = Number(m[2]) || 0;
const verb = m[1].toLowerCase();
if (verb === "add") setTotal(total() + number);
else if (verb === "set") setTotal(number);
else setTotal(Number(s.start) || 0);
});
The chat command only works for you and your mods: it checks roles and the badges before doing anything.
Every change is saved with widget.store, so the count is still there next stream:
function setTotal(value) {
const before = total();
count = Math.max(0, value) - (Number(s.start) || 0);
widget.store.set("count", count);
if (total() > before) boing = 1; // a happy wobble when it goes up
showNumbers(true);
}Step 4: Making it move
Everything that moves is redrawn in one function, about 60 times a second, using
requestAnimationFrame. Each frame it works out how full to draw it, easing towards the real
value so the goo glides up instead of jumping:
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const t = (now - t0) / 1000;
const goal = Math.min(1, Math.max(0, total() / target()));
shown += (goal - shown) * Math.min(1, dt * 2.5);
boing = Math.max(0, boing - dt * 0.9);
const p = shown;
const level = BOTTOM - (BOTTOM - TOP) * p; // the goo's surface (SVG y: smaller is higher)Greener as it fills: the goo's colour is mixed between the "nearly empty" and "full" colours by how full it is, the jelly takes on a green tint, and the glow gets stronger:
// Colours: the goo goes from pale to rich green, the jelly picks up a green tint, and it glows more as it fills.
const goo = mix(s.emptyColour || "#c8f59a", s.fullColour || "#13c24f", p);
$("goo-top").setAttribute("stop-color", mix(goo, "#ffffff", 0.25));
$("goo-deep").setAttribute("stop-color", mix(goo, "#063a18", 0.45));
$("goo-back").setAttribute("fill", mix(goo, "#063a18", 0.3));
$("jelly").setAttribute("fill", mix("#eef3f0", mix(s.fullColour || "#13c24f", "#ffffff", 0.7), p));
$("glow-shadow").setAttribute("flood-color", s.fullColour || "#13c24f");
$("glow-shadow").setAttribute("flood-opacity", (p * 0.6).toFixed(3));Mixing two colours is just mixing their red, green and blue numbers:
// A colour as [red, green, blue], from "#13c24f" (the settings) or "rgb(19,194,79)" (one mix() made).
const rgb = (colour) => {
const c = String(colour);
if (c.startsWith("rgb")) return c.match(/\d+/g).slice(0, 3).map(Number);
const n = parseInt(c.replace("#", "").slice(0, 6), 16) || 0;
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const mix = (a, b, t) => {
const [x, y] = [rgb(a), rgb(b)];
return `rgb(${x.map((v, i) => Math.round(v + (y[i] - v) * t)).join(",")})`;
};
The wobble: it squashes and stretches from the bottom (where it sits), wider when shorter, like jelly. A slow
gentle wobble is always on; after the goal goes up, a fast boing is added that fades away. The floor shadow
stretches with it.
// The wobble: a slow squash and stretch, from the bottom (where it sits), plus a bouncier one after a boing.
const squash = Math.sin(t * 3.1) * 0.018 + Math.sin(t * 15) * 0.07 * boing * boing;
const lean = Math.sin(t * 1.6) * 1.2;
$("creature").setAttribute("transform", `translate(${MID_X} ${BOTTOM}) rotate(${lean}) scale(${1 + squash} ${1 - squash}) translate(${-MID_X} ${-BOTTOM})`);
$("shadow").setAttribute("rx", (140 * (1 + squash * 1.6)).toFixed(1));The rolling surface: two sine waves added together, moving at different speeds and directions, give a surface that rolls rather than slides. The path joins points along the wave, then goes down round the bottom; the clip keeps it inside the slime.
// The goo's surface: two waves added together, moving at different speeds, so it rolls rather than slides.
const amp = 5 + 9 * boing;
const wave = (x, phase) => level + Math.sin(x * 0.035 + t * 2.2 + phase) * amp + Math.sin(x * 0.071 - t * 1.4 + phase) * amp * 0.45;
const surface = (phase, lift) => {
if (p <= 0.002) return "";
let d = `M0 ${(wave(0, phase) - lift).toFixed(1)}`;
for (let x = 10; x <= 400; x += 10) d += ` L${x} ${(wave(x, phase) - lift).toFixed(1)}`;
return `${d} L400 420 L0 420 Z`;
};
$("goo-back").setAttribute("d", surface(1.7, 5));
$("goo").setAttribute("d", surface(0, 0));Step 5: Bubbles and fizz
Both are tiny particle systems: a list of bubbles, each with a position and speed. Every frame, new ones may be
born, every one moves, and finished ones are removed. To start fizz on the goo inside the slime wherever the
surface is, the script first measures the slime's shape row by row with isPointInFill, so it works for
any shape you draw:
// Where the slime is, row by row: the left and right edge of its body at each height. Measured once from the SVG
// path itself (isPointInFill), so the fizz always starts on the goo, inside the slime, whatever shape you draw.
const body = $("body");
// An SVGPoint (not new DOMPoint): OBS's browser (Chrome 127) only takes an SVGPoint here.
const point = body.ownerSVGElement.createSVGPoint();
const inside = (x, y) => {
point.x = x;
point.y = y;
return body.isPointInFill(point);
};
const edges = [];
for (let y = 0; y <= 420; y += 2) {
let left = null;
let right = null;
for (let x = 0; x <= 400; x += 2) {
if (inside(x, y)) {
if (left === null) left = x;
right = x;
}
}
edges[y / 2] = left === null ? null : [left, right];
}
const edgesAt = (y) => edges[Math.max(0, Math.min(210, Math.round(y / 2)))];Each fizz bubble is born on the surface, floats up (fast, then slowing), wiggles, and then pops: for a fifth of a second it becomes a growing, fading ring with two specks flying off.
// Fizz: tiny bubbles born on the surface (inside the slime) that float up, wiggle, then pop into a ring.
const row = edgesAt(level);
if (p > 0.01 && row && row[1] - row[0] > 30 && Math.random() < dt * (3 + 9 * p)) {
const x = row[0] + 14 + Math.random() * (row[1] - row[0] - 28);
fizz.push({ x, y: wave(x, 0), r: 2.5 + Math.random() * 3.5, rise: 18 + Math.random() * 46, life: 0.7 + Math.random() * 0.8, age: 0 });
}
const fizzDrawn = [];
for (const f of fizz) {
f.age += dt;
const k = Math.min(1, f.age / f.life); // 0 → 1 while floating up
const x = f.x + Math.sin(f.age * 9 + f.x) * 2.5;
const y = f.y - f.rise * (1 - (1 - k) * (1 - k)); // fast at first, slowing near the top
if (f.age < f.life) {
fizzDrawn.push(circle(x, y, f.r, mix(goo, "#ffffff", 0.55), 0.9, s.outline, 1.5));
} else {
// The pop: a ring that grows and fades in a fifth of a second, with two specks flying off.
const q = Math.min(1, (f.age - f.life) / 0.2);
fizzDrawn.push(ring(x, y, f.r * (1 + q * 1.8), mix(goo, "#ffffff", 0.4), 1 - q));
fizzDrawn.push(circle(x - f.r * (1 + q * 2.5), y - q * 4, 1.2, "#ffffff", 1 - q), circle(x + f.r * (1 + q * 2.5), y - q * 5, 1.2, "#ffffff", 1 - q));
}
}
for (let i = fizz.length - 1; i >= 0; i--) if (fizz[i].age > fizz[i].life + 0.2) fizz.splice(i, 1);
draw("fizz", fizzDrawn);Make it yours: draw a new body in Inkscape and paste its path into id="body" (keep it inside 400 × 420): the goo, clip, outline and fizz all follow it. Make a blue slime water goal, or purple goo for a potion. Want more fizz? Raise the 3 + 9 * p in the fizz code.
Testing and sharing
- Test events: the app's Test buttons (in Recent and on triggers) send events to your widget too, marked
test. - Fresh look: after changing files, refresh the overlay in OBS (right-click → Refresh). After changing
fields.json, click Reload fields in the widget's settings. - Seeing errors: OBS hides them. Open your widget's address in a browser while the app is running (the address is in the widget's Copy URL): the browser's developer tools (F12) show what went wrong.
- Coming from StreamElements? Its
onWidgetLoadandonEventReceivedevents are sent too, in the same shape, so many custom widgets work with few changes. - Sharing: zip the folder. Others unzip it and pick the folder, as in Add one to the app.