Skip to main content

Examples

Alert payload explorer

Search any alert here and inspect the live example payload your overlay receives in Overlay.on('alert', (data) => ...).

Alert template variables like {{username}} and {{amount}} map to the same keys inside data.extraSettings. For the full variable reference, use the Platform Variables alert explorer. For the full payload type, use Overlay Type Definitions.

Live Overlay.on data

Selected alert: crowdcontrol-effect on crowdcontrol

Message template: {{username}} sent {{effect}}

Payload tree
data {4}
alert: "crowdcontrol-effect"
extraSettings {7}
username: "lumiastream"
effect: "Big Light"
game: "Lumia Stream"
avatar: "https://static-cdn.jtvnw.net/jtv_user_pictures/2b1fa336-f9b2-42cf-bd2c-98675da74982-profile_image-70x70.png"
artwork: "https://static-cdn.jtvnw.net/jtv_user_pictures/2b1fa336-f9b2-42cf-bd2c-98675da74982-profile_image-70x70.png"
message: "Turn on the lights"
duration: 5
fromLumia: true
dynamic {1}
value: "Big Light"
Overlay.on('alert') payload
{
"alert": "crowdcontrol-effect",
"extraSettings": {
"username": "lumiastream",
"effect": "Big Light",
"game": "Lumia Stream",
"avatar": "https://static-cdn.jtvnw.net/jtv_user_pictures/2b1fa336-f9b2-42cf-bd2c-98675da74982-profile_image-70x70.png",
"artwork": "https://static-cdn.jtvnw.net/jtv_user_pictures/2b1fa336-f9b2-42cf-bd2c-98675da74982-profile_image-70x70.png",
"message": "Turn on the lights",
"duration": 5
},
"fromLumia": true,
"dynamic": {
"value": "Big Light"
}
}

Custom Overlays Examples

Need to inspect what an alert returns? Open the alert explorer on the Platform Variables page to browse data.alert, data.extraSettings.*, and the fields available for each alert. For full Overlay.on(...) payload types, see Overlay Type Definitions.

Index

Grouped by what the overlay does, so you can jump to the closest starting point:

Alerts & chat

  • Custom Alert — single listener branches on data.alert for donations, subs, follows, bits, raids.
  • Custom Chat Box — renders chat messages with DOM APIs (no innerHTML).

Commands & games

External APIs (fetch)

HFX / lights / loyalty

  • HFX Listener BannerOverlay.on("hfx", ...) triggers an animated banner with the user, command, and message.
  • Virtual Light MonitorOverlay.on("virtuallight", ...) shows the current light color/brightness/power; persists across reloads with saveStorage + first-load null-check.
  • Loyalty Points LeaderboardOverlay.getLoyaltyPoints / addLoyaltyPoints + Overlay.chatbot for !points and !give commands.

Visual

Font Picker with Quoted Variable

Small reference overlay. The key pattern: fontpicker Configs produce a font-family string, and CSS needs that value inside quotes because font-family expects a string. Compare to colors/sizes, which must be unquoted.

HTML

<div id="label">Hello, streamer!</div>

CSS

#label {
/* quoted — font-family needs a string */
font-family: "{{font}}";
/* unquoted — color/size/number values are not strings */
color: {{labelColor}};
font-size: {{fontSize}}px;
padding: 16px 24px;
}

JS

// Nothing needed — CSS variable replacement renders the picked font automatically.

Configs

{
"font": {
"type": "fontpicker",
"label": "Font",
"order": 1
},
"labelColor": {
"type": "colorpicker",
"label": "Label color",
"order": 2,
"value": "#ffffff"
},
"fontSize": {
"type": "slider",
"label": "Font size",
"order": 3,
"options": { "min": 10, "max": 120, "step": 2, "suffix": "px" },
"value": 48
}
}

Data

{
"font": "Roboto",
"labelColor": "#ffffff",
"fontSize": 48
}

Custom Alert

JS Code

// Overlay.data is fetched from the sidebar input values
const container = document.getElementById("container");
const alertContainer = document.getElementById("alert-container");

// Pass data to css variable to use it easily inside css
if (Overlay.data?.primaryColor) container.style.setProperty("--primary", Overlay.data.primaryColor);
if (Overlay.data?.backgroundColor) container.style.setProperty("--secondary", Overlay.data.backgroundColor);
if (Overlay.data?.messageBgColor) container.style.setProperty("--bg", Overlay.data.messageBgColor);
if (Overlay.data?.textColor) container.style.setProperty("--text", Overlay.data.textColor);
if (Overlay.data?.rounded) container.style.setProperty("--rounded", Overlay.data.rounded);

// Listen for alerts
Overlay.on("alert", (data) => {
const alertType = data.alert;
const settings = data.extraSettings || {};
let fullMessage = "";

if (alertType === "streamlabs-donation" || alertType === "streamelements-donation" || alertType === "lumiastream-donation") {
fullMessage = `${settings.username || "Someone"} just tipped ${settings.amount ?? data.dynamic?.value ?? ""} ${settings.currency ?? ""}`;
} else if (alertType === "twitch-follower" || alertType === "kick-follower") {
fullMessage = `${settings.username || "Someone"} is now following!`;
} else if (alertType === "twitch-subscriber" || alertType === "kick-subscriber") {
fullMessage = `${settings.username || "Someone"} just subscribed!`;
} else if (alertType === "twitch-bits") {
fullMessage = `${settings.username || "Someone"} cheered ${data.dynamic?.value ?? settings.amount ?? ""} bits`;
} else if (alertType === "twitch-raid") {
fullMessage = `${settings.username || "Someone"} raided with ${data.dynamic?.value ?? settings.viewers ?? 0} viewers`;
} else {
return;
}

if (settings?.message) fullMessage += ` They said ${settings?.message}`;

alertContainer.textContent = "";
const messageEl = document.createElement("div");
messageEl.className = "message-container";

const avatarEl = document.createElement("img");
avatarEl.className = "avatar";
avatarEl.src = settings.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
avatarEl.alt = "";

const textEl = document.createElement("span");
textEl.textContent = fullMessage;

messageEl.append(avatarEl, textEl);
alertContainer.appendChild(messageEl);
});

HTML

<div id="container">
<h1>Custom Alert</h1>
<div id="alert-container"></div>
</div>

CSS Styling

#container {
--primary: #ff4076;
--secondary: #393853;
--bg: #12111d;
--text: #ffffff;
--rounded: 10px;
font-family: 'Courier New', Courier, monospace;
background-color: var(--secondary);
color: var(--text);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border-radius: var(--rounded);
}

#container h1 {
color: var(--primary);
text-align: center;
}

#alert-container {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 0.5rem;
}

#alertImage {
width: 150px;
}
.message-container {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
}
.avatar {
background: var(--secondary);
border-radius: 100px;
height: 32px;
aspect-ratio: 1 / 1;
}

Configs

{
"primaryColor": {
"type": "colorpicker",
"label": "Primary color:",
"order": 1
},
"backgroundColor": {
"type": "colorpicker",
"label": "Background color:",
"order": 2
},
"textColor": {
"type": "colorpicker",
"label": "Text color:",
"order": 3
},
"messageBgColor": {
"type": "colorpicker",
"label": "Message background color:",
"order": 4
},
"rounded": {
"type": "input",
"label": "Rounded corners:",
"order": 5
},
"alertImage": {
"type": "input",
"label": "Alert image URL:",
"order": 6
}
}

Data

{
"primaryColor": "#ff4076",
"backgroundColor": "#393853",
"textColor": "#ffffff",
"messageBgColor": "#12111d",
"rounded": "10px",
"alertImage": "https://storage.lumiastream.com/overlays/2/946e20c5-35da-44f6-94cb-fe833c71d10b.gif"
}

Custom Chat Box

JS Code

// Overlay.data is fetched from the sidebar input values
const messageContainer = document.getElementById("container");
const messagesContainer = document.getElementById("messages-container");

// Pass data to css variable to use it easily inside css
if (Overlay.data?.primaryColor) messageContainer.style.setProperty("--primary", Overlay.data.primaryColor);
if (Overlay.data?.backgroundColor) messageContainer.style.setProperty("--secondary", Overlay.data.backgroundColor);
if (Overlay.data?.messageBgColor) messageContainer.style.setProperty("--bg", Overlay.data.messageBgColor);
if (Overlay.data?.textColor) messageContainer.style.setProperty("--text", Overlay.data.textColor);
if (Overlay.data?.rounded) messageContainer.style.setProperty("--rounded", Overlay.data.rounded);

// Listen for chat messages
Overlay.on("chat", (data) => {
const messageEl = document.createElement("div");
messageEl.className = "message";

const originEl = document.createElement("span");
originEl.className = "origin";
originEl.textContent = `From ${data.origin}`;

const avatarEl = document.createElement("img");
avatarEl.className = "avatar";
avatarEl.src = data.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
avatarEl.alt = "";

const usernameEl = document.createElement("strong");
usernameEl.className = "username";
usernameEl.textContent = `${data.username}:`;

const contentEl = document.createElement("span");
contentEl.className = "content";
contentEl.textContent = data.message;

messageEl.append(originEl, avatarEl, usernameEl, contentEl);
messagesContainer.appendChild(messageEl);
});

HTML

<div id="container">
<h1>Custom Chat Box</h1>
<div id="messages-container" class="messages-container"></div>
</div>

CSS Styling

#container {
--primary: #ff4076;
--secondary: #393853;
--bg: #12111d;
--text: #ffffff;
--rounded: 10px;
font-family: 'Courier New', Courier, monospace;
background-color: var(--secondary);
color: var(--text);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border-radius: var(--rounded);
}
#container h1 {
color: var(--primary);
text-align: center;
}
.messages-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1rem;
background: var(--bg);
border-radius: 10px;
}
.avatar {
background: var(--secondary);
border-radius: 100px;
height: 32px;
aspect-ratio: 1 / 1;
}

Configs

{
"primaryColor": {
"type": "colorpicker",
"label": "Primary color:",
"order": 1
},
"backgroundColor": {
"type": "colorpicker",
"label": "Background color:",
"order": 2
},
"textColor": {
"type": "colorpicker",
"label": "Text color:",
"order": 3
},
"messageBgColor": {
"type": "colorpicker",
"label": "Message background color:",
"order": 4
},
"rounded": {
"type": "input",
"label": "Rounded corners:",
"order": 5
}
}

Data

{
"primaryColor": "#ff4076",
"backgroundColor": "#393853",
"textColor": "#ffffff",
"messageBgColor": "#12111d",
"rounded": "10px"
}

Calculator with overlaySendCustomContent from Lumia Stream

To create a calculator we need to take in data from Lumia Stream, so we will need Overlay Actions or Custom Code. This calculator also has a TTS option

Command Custom JS Code from Lumia Stream

This will take in a message from chat like a number or math operator and send it to the overlay with the codeId of calculator

async function() {
overlaySendCustomContent({ codeId: "calculator", content: "{{message}}" });
// Make sure you call done() to avoid memory leaks
done();
}

JS Code

const calcEl = document.getElementById('calc-container');
const exprEl = document.getElementById('expression');
const resultEl = document.getElementById('result');
const cfg = Overlay.data || {};

// Apply colors from sidebar
if (cfg.primaryColor) calcEl.style.setProperty('--primary', cfg.primaryColor);
if (cfg.backgroundColor) calcEl.style.setProperty('--background', cfg.backgroundColor);

let tokens = [];
let showingResult = false;

const IDLE_MS = cfg.idleTimeout ?? 20000;
let idleTimer = null;

function resetIdleTimer() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
tokens = [];
resultEl.textContent = '';
render();
}, IDLE_MS);
}

function speak(text, opts = {}) {
if (!cfg.tts) return;
const u = new SpeechSynthesisUtterance(text);
Object.assign(u, opts); // voice, rate, pitch, lang, etc.
window.speechSynthesis.cancel(); // stop anything currently talking
window.speechSynthesis.speak(u);
}

function verbalize(token) {
switch (token) {
case '+':
return 'plus';
case '-':
return 'minus';
case '*':
return 'times';
case '/':
return 'divided by';
case '=':
return 'equals';
default:
return token; // numbers & anything else
}
}

function render() {
exprEl.textContent = '';
tokens.forEach((t, i) => {
if (i > 0) exprEl.appendChild(document.createTextNode(' '));
const span = document.createElement('span');
span.className = /^[0-9.]+$/.test(t) ? 'token number' : 'token op';
span.textContent = t;
exprEl.appendChild(span);
});
}

function evaluate() {
const raw = tokens.join(' ');
try {
const safe = raw.replace(/[^0-9+\-*/.() ]/g, '');
const total = Function(`"use strict"; return (${safe})`)();

resultEl.textContent = '= ' + total;

// 🔊 read the answer
speak(`equals ${String(total)}`); // “forty-two”, “3.14159”, …
} catch {
resultEl.textContent = 'Error';
speak('Error');
}
showingResult = true;
}

Overlay.on('overlaycontent', (data) => {
const content = data.content;

// After displaying a result, any new token starts a new expression
if (showingResult) {
tokens = [];
resultEl.textContent = '';
showingResult = false;
}

// Clear
if (content === 'C') {
tokens = [];
resultEl.textContent = '';
speak('Clear');
render();
return;
}

// Evaluate
if (content === '=') {
if (tokens.length) evaluate();
return;
}

// Numbers (build multi-digit) vs operators
if (/^[0-9.]$/.test(content)) {
if (tokens.length && /^[0-9.]+$/.test(tokens.at(-1))) {
tokens[tokens.length - 1] += content; // extend current number
// speak(tokens[tokens.length - 1]);
} else {
tokens.push(content);
speak(verbalize(content));
}
} else if (/^[+\-*/]$/.test(content)) {
// If previous token is also an operator, overwrite it
if (tokens.length && /^[+\-*/]$/.test(tokens.at(-1))) {
tokens[tokens.length - 1] = content;
} else {
tokens.push(content);
}
speak(verbalize(content));
}
render();
});

HTML

<div id="calc-container">
<div id="expression"></div>
<div id="result"></div>
</div>

CSS

/* Big, bold, colourful ✨ */
@import url('https://fonts.googleapis.com/css2?family=Fredoka+One&display=swap');

#calc-container {
--bg: var(--background, #fff6d6);
--border: var(--primary, #ff90e8);
--pad: min(2vw, 20px);

font-family: 'Fredoka One', system-ui, sans-serif;
background: var(--bg);
border: 6px solid var(--border);
border-radius: 25px;
padding: var(--pad);
filter: drop-shadow(0 0 10px #0003);
}

#expression,
#result {
line-height: 1.2;
letter-spacing: 0.06em;
word-break: break-all;
/* huge on widescreen, scale down on mobile */
font-size: clamp(40px, 6vw, 80px);
text-align: center;
}

#expression span.token.number {
color: #ff595e; /* watermelon */
}
#expression span.token.op {
color: #1982c4; /* sky */
}

#result {
margin-top: 0.2em;
color: #8ac926; /* lime */
}

Configs

{
"tts": {
"type": "checkbox",
"label": "TTS",
"order": 1,
"value": false
},
"ttsVoice": {
"type": "input",
"label": "TTS Voice",
"order": 2,
"visibleIf": {
"key": "tts",
"equals": true
}
},
"idleTimeout": {
"type": "number",
"label": "Auto-clear after (ms)",
"order": 3,
"value": 20000,
"min": 5000,
"step": 1000
},
"primaryColor": {
"type": "colorpicker",
"label": "Text / border colour",
"order": 4,
"value": "#ffffff"
},
"backgroundColor": {
"type": "colorpicker",
"label": "Background colour",
"order": 5,
"value": "#00000080"
}
}

Data

{
"tts": false,
"ttsVoice": "",
"idleTimeout": 20000,
"primaryColor": "#ffffff",
"backgroundColor": "#00000080"
}

Roll a Dice

JS Code

const die = document.getElementById('die');
const cfg = Overlay.data || {};
const HIDE_MS = cfg.hideDelay ?? 5000;
let hideT = null;
let min = 1;
let max = 6;

if (cfg.dotColor) die.style.setProperty('--dot-color', cfg.dotColor);
die.style.setProperty('--roll-ms', `${cfg.rollDuration ?? 900}ms`);

function showDie() {
die.classList.remove('hidden');
clearTimeout(hideT);
hideT = setTimeout(() => die.classList.add('hidden'), HIDE_MS);
}

Overlay.on('chat', (data) => {
const message = data.message?.trim().toLowerCase();
const username = data.username ?? 'Someone';
if (message !== '!roll') return;

const v = min + Math.floor(Math.random() * (max - min + 1));

// restart spin animation
die.style.animation = 'none';
void die.offsetWidth;
die.style.animation = `spin var(--roll-ms) cubic-bezier(.26,.88,.37,.99)`;

// reveal value after spin
setTimeout(() => {
die.textContent = v;
}, cfg.rollDuration ?? 900);

// make it visible & schedule hide
showDie();
});

// hide die on first load
die.classList.add('hidden');

HTML

<div id="die" class="die">🎲</div>

CSS Styling

:root {
--size: 90px; /* die size */
--roll-ms: 900ms; /* spin length */
--dot-color: #fff;
}

.die {
width: var(--size);
height: var(--size);
display: grid;
place-content: center;
font-size: calc(var(--size) * 0.55);
color: var(--dot-color);
background: #0008;
border-radius: 12%;
user-select: none;
animation: none;
}

.die.hidden {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.die {
transition: opacity 0.3s ease;
}

/* single-run spin animation */
@keyframes spin {
0% {
transform: rotateX(0) rotateY(0);
}
70% {
transform: rotateX(3turn) rotateY(4turn);
}
100% {
transform: rotateX(3turn) rotateY(4turn);
}
}

Configs

{
"dotColor": {
"type": "colorpicker",
"label": "Dice text colour",
"order": 1,
"value": "#ffffff"
},
"rollDuration": {
"type": "number",
"label": "Spin time (ms)",
"order": 2,
"value": 900
},
"hideDelay": {
"type": "number",
"label": "Auto-hide after (ms)",
"order": 3,
"value": 5000
}
}

Data

{
"dotColor": "#ffffff",
"hideDelay": 5000,
"rollDuration": 900
}

Pokemon Catch Mini-Game Overlay

JS Code

/* ------------------------------------------------------------------
0. CONFIG (editable in “Configs” / “Data” tabs)
------------------------------------------------------------------*/
const cfg = {
catchCommand: Overlay.data?.catchCommand || '!catch',
leaderboardCommand: Overlay.data?.leaderboardCommand || '!leader',
pokedexCommand: Overlay.data?.pokedexCommand || '!pokedex',
minSpawnSec: Number(Overlay.data?.minSpawn) || 40,
maxSpawnSec: Number(Overlay.data?.maxSpawn) || 60,
captureMsgMs: Number(Overlay.data?.msgMs) || 7000,
maxDex: Number(Overlay.data?.maxDex) || 151,
volume: Number(Overlay.data?.volume) || 0.3,
shinyRate: Number(Overlay.data?.shinyRate) || 2,
catchSuccess: Number(Overlay.data?.catchPct) || 50,
fleeChance: Number(Overlay.data?.fleePct) || 15,
idleFleeSec: Number(Overlay.data?.idleSec) || 15,
disableChat: Boolean(Overlay.data?.disableChat),
devMode: Boolean(Overlay.data?.devMode),
};

/* ------------------------------------------------------------------
1. DOM handles
------------------------------------------------------------------*/
const imgPoke = document.getElementById('pokemon-image');
const imgBall = document.getElementById('pokeball-image');
const msgEl = document.getElementById('message');
const boardList = document.getElementById('board-list');
const modalDex = document.getElementById('pokedex');
const dexMsgEl = document.getElementById('pokedex-message');
const dexImgsEl = document.getElementById('pokedex-images');
const sfxSpawn = document.getElementById('spawn-sound');
const sfxCatch = document.getElementById('catch-sfx');
const sfxThrow = document.getElementById('throw-sfx');

/* ------------------------------------------------------------------
2. Global state
------------------------------------------------------------------*/
let game = { cur: null, catchable: false, anim: false, idleT: null, spawnT: null };
let msgTimer = null;
let leaderboardTimer = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const BALL_POWER = {
regular: 0, // baseline cfg.catchSuccess
great: +20, // +20 %
ultra: +40, // +40 %
master: 100, // always catches
};

const BALL_SPRITE = {
regular: 'https://storage.lumiastream.com/overlays/2/8f20685a-21f2-4f11-a0cf-2571ed3a65f3.png',
great: 'https://storage.lumiastream.com/overlays/2/92177744-1de7-4605-8a26-5c03ff2257cf.png',
ultra: 'https://storage.lumiastream.com/overlays/2/c8efaa7b-e983-4706-ad46-33e0ee547846.png',
master: 'https://storage.lumiastream.com/overlays/2/4cb9dbe2-6e9a-451b-bb11-20044011c9b5.png',
};

/* ------------------------------------------------------------------
3. Leaderboard helpers (Lumia Variables)
------------------------------------------------------------------*/
async function load() {
const raw = await Overlay.getVariable('pokemon_leaderboard');
try {
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}

async function save(data) {
await Overlay.setVariable('pokemon_leaderboard', JSON.stringify(data));
}

async function incrementUser(user, pokeName, isShiny) {
const board = await load();
if (!board[user]) board[user] = { total: 0, shiny: 0, list: [] };

board[user].total++;
if (isShiny) board[user].shiny++;
board[user].list.push({ name: pokeName, shiny: isShiny });

await save(board);
}

/* ------------------------------------------------------------------
4. Pokémon fetcher (forceId / forceShiny overrides)
------------------------------------------------------------------*/
async function fetchPokemon({ forceId = null, forceShiny = null } = {}) {
const id = forceId ?? Math.floor(Math.random() * cfg.maxDex) + 1;
try {
const data = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`).then((r) => r.json());
const shiny = forceShiny ?? Math.random() * 100 < cfg.shinyRate;

return {
id,
name: data.name.charAt(0).toUpperCase() + data.name.slice(1),
sprite: shiny ? data.sprites.front_shiny : data.sprites.front_default,
cry: data.cries.latest || data.cries.legacy,
shiny,
};
} catch {
return null;
}
}

/* ------------------------------------------------------------------
5. Game flow
------------------------------------------------------------------*/
function showMessage(txt = '', ms = 0) {
clearTimeout(msgTimer);

if (txt) {
msgEl.textContent = txt;
msgEl.classList.remove('hidden'); // <-- make sure you have a `.hidden` css rule
} else {
msgEl.textContent = '';
msgEl.classList.add('hidden');
}

if (ms > 0) {
msgTimer = setTimeout(() => showMessage('', 0), ms);
}
}

function scheduleSpawn() {
clearTimeout(game.spawnT);
const delay = (Math.random() * (cfg.maxSpawnSec - cfg.minSpawnSec) + cfg.minSpawnSec) * 1000;
game.spawnT = setTimeout(() => spawn(), delay);
}

async function spawn(overrides = {}) {
if (game.cur || game.anim) return;
game.anim = true;

imgBall.className = 'hidden';
imgPoke.className = 'hidden';

const p = await fetchPokemon(overrides);
if (!p) {
game.anim = false;
scheduleSpawn();
return;
}

game.cur = p;
imgPoke.src = p.sprite;
showMessage(p.shiny ? `✨ A SHINY ${p.name.toUpperCase()} appeared!` : `A wild ${p.name} appeared!`, 0);
game.catchable = true;

/* cry */
if (p.cry && cfg.volume > 0) {
sfxSpawn.src = p.cry;
sfxSpawn.volume = Math.min(1, Math.max(0, cfg.volume));
sfxSpawn.play().catch(() => {});
}

imgPoke.classList.remove('hidden');
imgPoke.classList.add('spawned');

setTimeout(() => {
game.anim = false;
showMessage(`${p.name} appeared! Type ${cfg.catchCommand} to catch it!`, 0);
}, 1500);

/* boredom flee */
clearTimeout(game.idleT);
game.idleT = setTimeout(() => {
if (game.cur && !game.anim) {
showMessage(`${game.cur.name} got bored and fled! 💨`, 3000); // 👈 3 s
despawn();
}
}, cfg.idleFleeSec * 1000);
}

/* ------------------------------------------------------------------
Attempt a capture
@param {string} username – display name
@param {string} origin – 'kick' | 'twitch' | 'youtube' | 'discord'
@param {string} ballType – 'regular' | 'great' | 'ultra' | 'master'
------------------------------------------------------------------*/
async function tryCatch({ username, origin = 'twitch', ballType = 'regular' }) {
if (!game.catchable || game.anim) return; // nothing to catch

/* ---------- pre-throw setup ---------- */
game.anim = true;
game.catchable = false;

// Capitalise the label used in the UI message
const ballLabel = ballType.charAt(0).toUpperCase() + ballType.slice(1);

showMessage(`${username} threw a ${ballLabel} Ball!`, 0);
imgBall.src = BALL_SPRITE[ballType] || BALL_SPRITE.regular;
imgBall.className = 'start-capture-sequence';

// SFX
sfxThrow.currentTime = 0;
sfxThrow.volume = Math.min(1, Math.max(0, cfg.volume));
sfxThrow.play().catch(() => {});

setTimeout(() => imgPoke.classList.add('hidden'), 700);
await sleep(5000);
imgBall.className = 'hidden';

/* ---------- success roll ---------- */
let success;
if (ballType === 'master') {
success = true; // Master Ball never fails
} else {
const bonus = BALL_POWER[ballType] ?? 0; // fall back to 0
const chance = Math.min(100, cfg.catchSuccess + bonus);
success = Math.random() * 100 < chance;
}

/* ---------- outcome ---------- */
if (success) {
// -------------------- catch! --------------------
sfxCatch.currentTime = 0;
sfxCatch.volume = Math.min(1, Math.max(0, cfg.volume));
sfxCatch.play().catch(() => {});
showMessage(`Gotcha! ${username} caught ${game.cur.name}! 🎉`, 3000);
Overlay.chatbot({
message: `Gotcha! ${username} caught ${game.cur.name}! 🎉`,
platform: origin,
chatAsSelf: false,
});

await incrementUser(username.toLowerCase(), game.cur.name, game.cur.shiny);

await sleep(cfg.captureMsgMs);
despawn();
} else {
// -------------------- broke free --------------------
showMessage(`${game.cur.name} broke free!`, 5000);

imgPoke.classList.remove('hidden'); // show Pokémon again

/* restart the idle-flee timer */
clearTimeout(game.idleT);
game.idleT = setTimeout(() => {
if (game.cur && !game.anim) {
showMessage(`${game.cur.name} got bored and fled! 💨`, 5000);
despawn();
}
}, cfg.idleFleeSec * 1000);

game.catchable = true;
game.anim = false;
}
}

function despawn() {
clearTimeout(game.idleT);
imgBall.classList.add('hidden');
imgPoke.classList.add('hidden');
game = { cur: null, catchable: false, anim: false, idleT: null, spawnT: null };
showMessage();
scheduleSpawn();
}

/* ------------------------------------------------------------------
6. Pokédex pop-up (reads leaderboard data)
------------------------------------------------------------------*/
async function showPokedex(username = 'Someone') {
const all = await load();
const entry = all[username.toLowerCase()] || { list: [] };
const list = entry.list;

dexMsgEl.textContent = list.length ? `${username} has caught ${list.length} / ${cfg.maxDex}` : `${username} hasn’t caught anything yet.`;

dexImgsEl.textContent = '';
list.forEach((p) => {
const slug = p.name.toLowerCase().replace(//g, 'f').replace(//g, 'm').replace(/[.'’]/g, '').replace(/\s+/g, '-');
const img = document.createElement('img');
img.className = 'poke' + (p.shiny ? ' shiny' : '');
img.src = `https://img.pokemondb.net/sprites/home/${p.shiny ? 'shiny' : 'normal'}/${slug}.png`;
img.alt = p.name;
dexImgsEl.appendChild(img);
});

modalDex.classList.remove('hidden');
setTimeout(() => modalDex.classList.add('hidden'), 10000);
}

async function showLeaderboard() {
clearTimeout(leaderboardTimer);
const board = await load();
const list = Object.entries(board)
.sort((a, b) => b[1].total - a[1].total)
.slice(0, 10);

boardList.textContent = '';
if (!list.length) {
const li = document.createElement('li');
li.textContent = 'No catches yet';
boardList.appendChild(li);
} else {
list.forEach(([u, d]) => {
const tip = d.list.map((p) => (p.shiny ? `${p.name}` : p.name)).join(', ');
const li = document.createElement('li');
const userSpan = document.createElement('span');
userSpan.className = 'user';
userSpan.title = tip;
userSpan.textContent = u;
const totalSpan = document.createElement('span');
totalSpan.textContent = d.total + (d.shiny ? `${d.shiny}` : '');
li.append(userSpan, totalSpan);
boardList.appendChild(li);
});
}

boardList.classList.remove('hidden');

leaderboardTimer = setTimeout(() => {
boardList.classList.add('hidden');
}, 10000);
}

/* ------------------------------------------------------------------
7. Chat listeners
------------------------------------------------------------------*/
const handleListener = ({ command, username, ballType, origin }) => {
if (command === cfg.catchCommand) tryCatch({ username, origin, ballType });
if (command === cfg.leaderboardCommand) showLeaderboard();
if (command === cfg.pokedexCommand) showPokedex(username);
};

if (!cfg.disableChat) {
Overlay.on('chat', (data) => {
handleListener({ command: data.message?.trim(), username: data.username, ballType: 'regular', origin: data.origin });
});
}

// Manual Listeners from Lumia
Overlay.on('overlaycontent', (data) => {
const { command, username, ballType } = JSON.parse(data.content);
handleListener({ command, username, ballType, origin: data.origin });
});

/* ------------------------------------------------------------------
8. Developer helpers (keyboard + console)
------------------------------------------------------------------*/
if (cfg.devMode) {
console.info('[Poké-Overlay] Dev-mode ON – S:spawn ⇧S:shiny R:rare C:catch M:Master P:pokedex L:leaderboard');

/* onscreen cheat-sheet */
const hint = document.getElementById('debug-hints');
if (hint) {
hint.innerHTML = 'Dev-mode: <kbd>S</kbd> Spawn • <kbd>⇧S</kbd> Shiny • <kbd>R</kbd> Rare • ' + '<kbd>C</kbd> Catch • <kbd>M</kbd> Masterball • <kbd>P</kbd> Pokédex • <kbd>L</kbd> Leaderboard';
hint.classList.remove('hidden');
}

/* helpers */
const rareIds = [144, 145, 146, 150, 151];
const spawnShiny = () => spawn({ forceShiny: true });
const spawnRare = () => spawn({ forceId: rareIds[Math.floor(Math.random() * rareIds.length)] });

window.debug = {
spawn,
spawnShiny,
spawnRare,
tryCatch,
showPokedex,
showLeaderboard,
state: async () => ({ game, board: await load() }),
};

window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const k = e.key.toLowerCase();
if (k === 's' && e.shiftKey) return spawnShiny();
if (k === 's') return spawn();
if (k === 'r') return spawnRare();
if (k === 'c') return tryCatch({ username: 'Dev' });
if (k === 'm') return tryCatch({ username: 'Dev', ballType: 'master' });
if (k === 'l') return showLeaderboard();
if (k === 'p') return showPokedex('Dev');
});
}

/* ------------------------------------------------------------------
9. Boot
------------------------------------------------------------------*/
// Give it a little time for variables to make it in
setTimeout(async () => {
// Sets the initial pokeball sprite
imgBall.src = BALL_SPRITE.regular;
scheduleSpawn();
showLeaderboard();
}, 1000);

HTML

<div class="container">
<!-- Game area -->
<div id="game-area">
<img id="pokemon-image" class="hidden" alt="Pokémon" />
<img id="pokeball-image" class="hidden" alt="Poké Ball" />
<p id="message"></p>
</div>

<!-- Leaderboard -->
<ul id="board-list"></ul>

<!-- Sounds -->
<audio id="spawn-sound" preload="auto"></audio>
<audio id="catch-sfx" preload="auto"><source src="https://storage.lumiastream.com/overlays/1/1c3c7e1a-da7b-4b3c-8efa-d5792f5d7011.mp3" type="audio/mpeg" /></audio>
<audio id="throw-sfx" preload="auto"><source src="https://storage.lumiastream.com/overlays/1/5e8753b9-1b9b-41be-ba20-27af95282e62.wav" type="audio/mpeg" /></audio>

<!-- Pokédex pop-up -->
<div id="pokedex" class="pokedex-container hidden">
<div id="pokedex-message"></div>
<div id="pokedex-images"></div>
</div>

<!-- Dev-mode cheat-sheet (shown only when devMode = true) -->
<div id="debug-hints" class="debug-hints hidden">
Dev-mode:&nbsp;<kbd>S</kbd> Spawn&nbsp;&nbsp;<kbd>⇧S</kbd> Shiny&nbsp;&nbsp; <kbd>R</kbd> Rare&nbsp;&nbsp;<kbd>C</kbd> Catch&nbsp;&nbsp;<kbd>P</kbd> Pokédex
</div>
</div>

CSS Styling

/* ------------------------------------------------------------------
0. Font
------------------------------------------------------------------*/
@font-face {
font-family: 'PokemonRBY';
src: url('https://db.onlinewebfonts.com/t/65780863e7302fe257df982341d6a1cb.woff2') format('woff2'), url('https://db.onlinewebfonts.com/t/65780863e7302fe257df982341d6a1cb.woff') format('woff'),
url('https://db.onlinewebfonts.com/t/65780863e7302fe257df982341d6a1cb.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}

/* ------------------------------------------------------------------
1. Layout
------------------------------------------------------------------*/
html,
body {
height: 100%;
width: 100%;
margin: 0;
font-family: 'PokemonRBY', sans-serif;
overflow: hidden;
}

.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}

#game-area {
position: relative; /* → origin for absolute Poké-ball */
display: flex;
flex-direction: column;
align-items: center;
}

/* ------------------------------------------------------------------
2. Pokémon & ball
------------------------------------------------------------------*/
#pokemon-image {
width: 250px;
height: 250px;
object-fit: contain;
transition: opacity 0.5s ease, transform 0.5s ease;
image-rendering: pixelated;
z-index: 10;
}

#pokeball-image {
position: absolute;
/* centred on the sprite before animation starts */
top: calc(50% - 125px);
left: 50%;
transform: translate(-50%, -50%) scale(0);
transform-origin: center center;
opacity: 0;
visibility: hidden;
z-index: 20;
pointer-events: none;
}

/* ------------------------------------------------------------------
3. Message text
------------------------------------------------------------------*/
#message {
text-align: center;
color: #ffcb05;
font-size: 28px;
letter-spacing: 2px;
margin-top: 10px;
text-shadow: 3px 3px 0 #3a5b8a, -3px 3px 0 #3a5b8a, 3px -3px 0 #3a5b8a, -3px -3px 0 #3a5b8a;
height: 80px;
}

/* ------------------------------------------------------------------
4. Leaderboard
------------------------------------------------------------------*/
#board-list {
list-style: none;
margin: 0;
padding: 0 12px;
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
border-radius: 8px;
font-size: 18px;
color: white;
}
#board-list li {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 2px 0;
white-space: nowrap;
}
#board-list .user {
cursor: default;
}
#board-list span:last-child {
font-weight: bold;
}

/* ------------------------------------------------------------------
5. Pokédex pop-up
------------------------------------------------------------------*/
#pokedex {
position: absolute;
bottom: 30px;
right: 60px;
padding: 20px;
background: rgba(255, 255, 255, 0.8);
border-radius: 10px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.5);

opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease;
}
#pokedex:not(.hidden) {
opacity: 1;
visibility: visible;
}

.pokedex-message {
font-size: 24px;
color: #3a5b8a;
margin-bottom: 10px;
}
.pokedex-container img {
width: 50px;
height: 50px;
margin: 5px;
}

/* ------------------------------------------------------------------
6. Utility + animations
------------------------------------------------------------------*/
.hidden {
opacity: 0 !important;
visibility: hidden !important;
transform: scale(0) !important;
}
.spawned {
opacity: 1;
visibility: visible;
transform: scale(1);
animation: pulse 2.5s ease-in-out infinite;
}
.poke {
width: 80px;
margin: 4px;
image-rendering: crisp-edges;
}
.shiny {
filter: hue-rotate(45deg) brightness(1.3) saturate(1.2);
}

@keyframes pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}

/* Throw + arc + shake + flash */
@keyframes full-capture-sequence {
0% {
transform: translate(-50%, -50%) translate(-300px, 150px) scale(0.5) rotate(-200deg);
opacity: 0;
}
8% {
opacity: 1;
}
14% {
transform: translate(-50%, -50%) translate(0, -140px) scale(1.2) rotate(0deg);
}
24% {
transform: translate(-50%, -50%) translate(0, 0) scale(1);
}
35% {
transform: translate(-50%, -50%) rotate(-15deg);
}
45% {
transform: translate(-50%, -50%) rotate(15deg);
}
55% {
transform: translate(-50%, -50%) rotate(-15deg);
}
65% {
transform: translate(-50%, -50%) rotate(15deg);
}
75% {
transform: translate(-50%, -50%) rotate(-15deg);
}
85% {
transform: translate(-50%, -50%) rotate(15deg);
}
90% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(0deg);
filter: drop-shadow(0 0 10px white);
}
}

.start-capture-sequence {
visibility: visible !important;
opacity: 1 !important;
animation: full-capture-sequence 5s ease-out forwards;
}

/* ------------------------------------------------------------------
7. Dev cheat-sheet bar
------------------------------------------------------------------*/
.debug-hints {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 14px;
padding: 4px 14px;
border-radius: 6px;
pointer-events: none;
}
.debug-hints kbd {
background: #333;
padding: 0 4px;
border-radius: 4px;
font-size: 13px;
}

Configs

{
"catchCommand": {
"type": "input",
"label": "Catch command",
"order": 1,
"value": "!catch"
},
"pokedexCommand": {
"type": "input",
"label": "Pokedex command",
"order": 2,
"value": "!pokedex"
},
"leaderboardCommand": {
"type": "input",
"label": "Leaderboard command",
"order": 3,
"value": "!leader"
},
"disableChat": {
"type": "checkbox",
"label": "Disable chat input (Lumia only)",
"order": 4,
"value": false
},
"devMode": {
"type": "checkbox",
"label": "Developer mode (keyboard helpers)",
"order": 5,
"value": false
},
"minSpawn": {
"type": "number",
"label": "Spawn delay (min s)",
"order": 6,
"value": 40
},
"maxSpawn": {
"type": "number",
"label": "Spawn delay (max s)",
"order": 7,
"value": 60
},
"msgMs": {
"type": "number",
"label": "Capture message duration (ms)",
"order": 8,
"value": 7000
},
"idleSec": {
"type": "number",
"label": "Flee time (s)",
"order": 8,
"value": 30
},
"fleePct": {
"type": "number",
"label": "Flee chance %",
"order": 9,
"value": 15
},
"catchPct": {
"type": "number",
"label": "Catch success %",
"order": 10,
"value": 40
},
"shinyRate": {
"type": "number",
"label": "Shiny rate %",
"order": 11,
"value": 2
},
"maxDex": {
"type": "number",
"label": "Highest Pokédex #",
"order": 12,
"value": 151
},
"volume": {
"type": "number",
"label": "SFX volume (0-1)",
"order": 13,
"value": 0.3
}
}

Data

{
"maxDex": 151,
"volume": 0.3,
"devMode": false,
"fleePct": 15,
"idleSec": 30,
"catchPct": 40,
"maxSpawn": 60,
"minSpawn": 40,
"msgMs": 7000,
"shinyRate": 2,
"disableChat": false,
"catchCommand": "!catch",
"pokedexCommand": "!pokedex",
"leaderboardCommand": "!leader"
}

Anime Facts using Fetch

JS Code

/* ---------- Config ---------- */
const DEFAULT_SECS = Number(Overlay.data.displaySecs) || 30;

/* ---------- DOM handles ---------- */
const wrapEl = document.getElementById('wrap');
const coverEl = document.getElementById('cover');
const quoteEl = document.getElementById('quote');
const animeEl = document.getElementById('anime');
const episodeEl = document.getElementById('episode');

let hideTimer;

/* ---------- Core ---------- */
async function fetchQuote() {
// 1- Get random quote
const res = await fetch('https://api.animechan.io/v1/quotes/random');
const {
content,
anime: { id: animeId, name: animeName },
character: { name: charName },
episode,
} = (await res.json()).data;

quoteEl.textContent = `"${content}" — ${charName}`;
animeEl.textContent = animeName;

// 2- Episode info
if (episode) {
episodeEl.textContent = `Episode ${episode}`;
} else {
// fallback: total episode count from Animechan’s /anime/<id>
try {
const info = await fetch(`https://api.animechan.io/v1/anime/${animeId}`).then((r) => r.json());
episodeEl.textContent = `${info.data.episodeCount} eps`;
} catch {
episodeEl.textContent = 'Episode ?';
}
}

// 3- Cover via Jikan
try {
const jikan = await fetch(`https://api.jikan.moe/v4/anime?q=${encodeURIComponent(animeName)}&limit=1`).then((r) => r.json());
coverEl.src = jikan.data?.[0]?.images?.jpg?.large_image_url || jikan.data?.[0]?.images?.jpg?.image_url || 'https://storage.lumiastream.com/placeholderLogo.png';
} catch {
coverEl.src = 'https://storage.lumiastream.com/placeholderLogo.png';
}
}

function showOverlay() {
clearTimeout(hideTimer);
wrapEl.classList.remove('hidden');
fetchQuote();
hideTimer = setTimeout(() => wrapEl.classList.add('hidden'), DEFAULT_SECS * 1000);
}

/* ---------- Event bridge from Lumia ---------- */
Overlay.on('chat', (data) => {
console.log('Chat');
if (data.message === '!fact') {
showOverlay();
}
});

showOverlay();

HTML

<div id="wrap" class="hidden">
<img id="cover" />
<blockquote id="quote"></blockquote>
<p id="meta"><span id="anime"></span><span id="episode"></span></p>
</div>

CSS Styling

#wrap {
--bg: {{backgroundColor}};
--primary: {{primaryColor}};
--text: {{textColor}};
font-family: "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
padding: 1rem;
border-radius: 10px;
max-width: 480px;
text-align: center;
}

#wrap.hidden { display: none; }

#cover {
width: 100%;
max-height: 260px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 0.8rem;
}

blockquote {
font-size: 1.1rem;
margin: .5rem 0;
color: var(--primary);
}

#meta { font-size: .9rem; opacity: .8; }

Configs

{
"displaySecs": {
"type": "number",
"label": "Display time (sec)",
"order": 1,
"value": 30,
"min": 5,
"max": 120
},
"primaryColor": {
"type": "colorpicker",
"label": "Accent colour",
"order": 2,
"value": "#ff577f"
},
"textColor": {
"type": "colorpicker",
"label": "Text colour",
"order": 3,
"value": "#ffffff"
},
"backgroundColor": {
"type": "colorpicker",
"label": "Background",
"order": 4,
"value": "#1e1e2f"
}
}

Data

{
"textColor": "#ffffff",
"displaySecs": 30,
"primaryColor": "#ff577f",
"backgroundColor": "#1e1e2f"
}

Pet Cam Random Dog/Cat Images using Fetch

JS Code

/* ---------- Config ---------- */
const DEFAULT_SECS = Number(Overlay.data.displaySecs) || 30;

/* ---------- DOM ---------- */
const wrapEl = document.getElementById('pet-wrap');
const imgEl = document.getElementById('pet-img');
const nameEl = document.getElementById('pet-name');
const factEl = document.getElementById('pet-fact');

let hideTimer;

/* ---------- Helpers ---------- */
async function getDog() {
const [pic, fact] = await Promise.all([
fetch('https://dog.ceo/api/breeds/image/random')
.then((r) => r.json())
.then((d) => d.message),
fetch('https://dog-api.kinduff.com/api/facts')
.then((r) => r.json())
.then((d) => d.facts?.[0] || 'Dogs are awesome!'),
]);
return { img: pic, name: 'Random Dog', fact };
}

async function getCat() {
const [imgPath, fact] = await Promise.all([
fetch('https://cataas.com/cat?json=true')
.then((r) => r.json())
.then((d) => `https://cataas.com${d.url}`),
fetch('https://catfact.ninja/fact')
.then((r) => r.json())
.then((d) => d.fact),
]);
return { img: imgPath, name: 'Random Cat', fact };
}

async function fetchPet(animal) {
switch (animal.toLowerCase()) {
case 'cat':
return getCat();
default:
return getDog(); // fallback = dog
}
}

async function showOverlay(animal) {
const { img, name, fact } = await fetchPet(animal || 'dog');

clearTimeout(hideTimer);
imgEl.src = img;
nameEl.textContent = name;
factEl.textContent = fact;

wrapEl.classList.remove('hidden');
hideTimer = setTimeout(() => wrapEl.classList.add('hidden'), DEFAULT_SECS * 1000);
}

/* ---------- Chat Listener ---------- */
Overlay.on('chat', async (data) => {
if (data.message.startsWith('!pet')) {
const animal = data.message.split(' ')[1];
showOverlay(animal);
}
});

showOverlay();

HTML

<div id="pet-wrap" class="hidden">
<img id="pet-img" />
<h3 id="pet-name"></h3>
<p id="pet-fact"></p>
</div>

CSS Styling

#pet-wrap {
--bg: {{bgColor}};
--accent: {{accentColor}};
--text: {{textColor}};

font-family: "Segoe UI", Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
padding: 1rem;
border-radius: 10px;
max-width: 100%;
text-align: center;
overflow: hidden;
height: 100%;
}

#pet-wrap.hidden { display: none; }

#pet-img {
width: 100%;
max-height: 85%;
object-fit: contain;
border-radius: 8px;
margin-bottom: 0.75rem;
}

#pet-name { margin: 0.25rem 0; color: var(--accent); }
#pet-fact { font-size: 0.95rem; line-height: 1.3; }

Configs

{
"displaySecs": {
"type": "number",
"label": "Display time (sec)",
"order": 1,
"value": 30,
"min": 5,
"max": 120
},
"accentColor": {
"type": "colorpicker",
"label": "Accent colour",
"order": 2,
"value": "#ffb347"
},
"textColor": {
"type": "colorpicker",
"label": "Text colour",
"order": 3,
"value": "#ffffff"
},
"bgColor": {
"type": "colorpicker",
"label": "Background colour",
"order": 4,
"value": "#1e1e2f"
}
}

Data

{
"bgColor": "transparent",
"textColor": "#ffffff",
"accentColor": "#ffb347",
"displaySecs": 30
}

Art Canvas

JS Code

/* ------------------------------------------------------------------ */
/* 0. Responsive transparent canvas setup */
/* ------------------------------------------------------------------ */
const cvs = document.getElementById("canvas");
const list = document.getElementById("chat-list");
const ctx = cvs.getContext("2d");

function fitCanvas() {
const dpr = window.devicePixelRatio || 1;
const rect = cvs.getBoundingClientRect();
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (w === cvs.width && h === cvs.height) return; // size unchanged
cvs.width = w;
cvs.height = h;
ctx.scale(dpr, dpr);
}
fitCanvas();
window.addEventListener("resize", fitCanvas);

/* ------------------------------------------------------------------ */
/* 1. Drawing helpers */
/* ------------------------------------------------------------------ */
const defaultColor = Overlay.data?.penColor || "#000000";
const defaultSize = Number(Overlay.data?.penSize) || 3;

function drawLine(x1, y1, x2, y2, color = defaultColor, size = defaultSize) {
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}

function drawCurve(
x1,
y1,
cx,
cy,
x2,
y2,
color = defaultColor,
size = defaultSize
) {
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(cx, cy, x2, y2);
ctx.stroke();
}

function drawStroke(pts, color = defaultColor, size = defaultSize) {
if (pts.length < 2) return;
ctx.strokeStyle = color;
ctx.lineWidth = size;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
ctx.stroke();
}

/* ------------------------------------------------------------------ */
/* 2. Runtime config & helpers */
/* ------------------------------------------------------------------ */
const cfg = Overlay.data ?? {};

/* ------------------------------------------------------------------ */
/* Startup “how-to” message */
/* ------------------------------------------------------------------ */
if (cfg.showStartupMessage !== false) {
const help =
"👋 Art Canvas active! Draw with " +
"`!line x1 y1 x2 y2 [#hex] [size]`, " +
"`!stroke x1,y1 x2,y2 …`, " +
"`!curve x1 y1 cx cy x2 y2`, " +
"and `!clear`. Paste multiple commands in one chat line " +
"— each starting with `!`. Mods can use `!subscribersonly`, " +
"`!followers`, `!modonly`, `!anyone`, `!chatoff`, `!chaton` " +
"to change who may draw.";
Overlay.chatbot({ message: `[Art Canvas] ${help}` });
}

/* Chat on/off & allowed levels can be changed at runtime (mod cmds) */
let chatEnabled = cfg.allowChat !== false;
let allowedLevelsSet = new Set(cfg.userLevels ?? ["anyone"]);

/* Map for quick check when userLevels is present in ChatEvent */
function userAllowed(userLevels) {
if (!chatEnabled) return false;
const L = allowedLevelsSet;
if (L.has("anyone")) return true;

if (userLevels.isSelf) return true; // streamer always allowed
if (L.has("mod") && userLevels.mod) return true;
if (L.has("vip") && userLevels.vip) return true;
if (L.has("subscriber") && userLevels.subscriber) return true;
if (L.has("tier2") && userLevels.tier2) return true;
if (L.has("tier3") && userLevels.tier3) return true;
if (L.has("follower") && userLevels.follower) return true;
return false;
}

/* ------------------------------------------------------------------ */
/* 3. Command parser (multi-command, single-line) */
/* ------------------------------------------------------------------ */
function processCommands(raw) {
if (!raw) return;
const tokens = raw.trim().split(/\s+/);
let i = 0;

while (i < tokens.length) {
const cmd = tokens[i]?.toLowerCase();
if (!cmd?.startsWith("!")) {
i++;
continue;
}

/* ---------- drawing commands --------------------------- */
if (cmd === "!clear") {
ctx.clearRect(0, 0, cvs.width, cvs.height);
i++;
continue;
}

if (cmd === "!line" && i + 4 < tokens.length) {
const x1 = +tokens[i + 1],
y1 = +tokens[i + 2],
x2 = +tokens[i + 3],
y2 = +tokens[i + 4];
let color = defaultColor,
size = defaultSize,
next = i + 5;
if (tokens[next] && /^#[0-9a-f]{3,8}$/i.test(tokens[next])) {
color = tokens[next];
next++;
}
if (tokens[next] && /^\d+(\.\d+)?$/.test(tokens[next])) {
size = +tokens[next];
next++;
}
drawLine(x1, y1, x2, y2, color, size);
i = next;
continue;
}

if (cmd === "!curve" && i + 6 < tokens.length) {
const x1 = +tokens[i + 1],
y1 = +tokens[i + 2],
cx = +tokens[i + 3],
cy = +tokens[i + 4],
x2 = +tokens[i + 5],
y2 = +tokens[i + 6];
let color = defaultColor,
size = defaultSize,
next = i + 7;
if (tokens[next] && /^#[0-9a-f]{3,8}$/i.test(tokens[next])) {
color = tokens[next];
next++;
}
if (tokens[next] && /^\d+(\.\d+)?$/.test(tokens[next])) {
size = +tokens[next];
next++;
}
drawCurve(x1, y1, cx, cy, x2, y2, color, size);
i = next;
continue;
}

if (cmd === "!stroke" && i + 2 < tokens.length) {
let j = i + 1;
const pts = [];
while (j < tokens.length && !tokens[j].startsWith("!")) {
if (/^\d+,\d+$/.test(tokens[j]))
pts.push(tokens[j].split(",").map(Number));
else break;
j++;
}
if (pts.length >= 2) {
let color = defaultColor,
size = defaultSize;
if (tokens[j] && /^#[0-9a-f]{3,8}$/i.test(tokens[j])) {
color = tokens[j];
j++;
}
if (tokens[j] && /^\d+(\.\d+)?$/.test(tokens[j])) {
size = +tokens[j];
j++;
}
drawStroke(pts, color, size);
}
i = j;
continue;
}

/* ---------- unrecognised ------------------------------- */
i++;
}
}

/* ------------------------------------------------------------------ */
/* 4. Mod-only runtime commands (do NOT change Overlay.data) */
/* ------------------------------------------------------------------ */
const allowModCommands = cfg.allowModCommands !== false;

function tryHandleModCommand(evt) {
if (!allowModCommands) return false;

const isModOrBroad = evt.userLevels?.mod || evt.userLevels?.isSelf;

if (!isModOrBroad) return false;

const msg = evt.message.toLowerCase();
if (msg.startsWith("!chatoff")) {
chatEnabled = false;
return true;
}
if (msg.startsWith("!chaton")) {
chatEnabled = true;
return true;
}

if (msg.startsWith("!subscribersonly") || msg.startsWith("!subscriberonly")) {
allowedLevelsSet = new Set(["subscriber", "vip", "mod", "broadcaster"]);
return true;
}
if (msg.startsWith("!modonly")) {
allowedLevelsSet = new Set(["mod", "broadcaster"]);
return true;
}
if (msg.startsWith("!followers")) {
allowedLevelsSet = new Set([
"follower",
"subscriber",
"vip",
"mod",
"broadcaster",
]);
return true;
}
if (msg.startsWith("!anyone")) {
allowedLevelsSet = new Set(["anyone"]);
return true;
}
return false;
}

/* helper to print feedback in chat via overlay bot */
function sys(msg) {
Overlay.chatbot({ message: `[Art Canvas] ${msg}` });
}

/* ------------------------------------------------------------------ */
/* 5. Listeners: Chat, Lumia, Alerts */
/* ------------------------------------------------------------------ */

/* Chat ------------------------------------------------------------- */
if (cfg.allowChat !== false) {
Overlay.on("chat", (e) => {
/* mod commands first */
if (tryHandleModCommand(e)) {
sys("Runtime chat rules updated.");
return;
}
if (e.username?.toLowerCase() === "lumiastream" || e.username?.toLowerCase() === "@lumiastream") return;
/* drawing commands */
if (userAllowed(e.userLevels ?? {})) processCommands(e.message);
}
);
}

/* Lumia overlaycontent --------------------------------------------- */
if (cfg.allowLumia) {
Overlay.on("overlaycontent", (d) => {
processCommands(d.content);
}
);
}

/* Alerts (Bits & Points) ------------------------------------------- */
Overlay.on("alert", (data) => {
const alert = data.alert;
const message = data.extraSettings?.message || "";

/* Bits */
if (alert === "twitch-bits" && cfg.allowBits) {
const bits = data.dynamic.value ?? 0;
if (bits >= (cfg.minBits ?? 1)) {
processCommands(message);
}
return;
}

/* Twitch Channel Points */
if (alert === "twitch-points" && cfg.allowTwitchPoints) {
const trig = cfg.twitchPointsCommand?.toLowerCase() || "";
const content = message.toLowerCase();
if (!trig || trig === data.extraSettings.command) processCommands(content);
return;
}

/* Kick Points */
if (alert === "kick-points" && cfg.allowKickPoints) {
const trig = cfg.kickPointsCommand?.toLowerCase() || "";
const content = message.toLowerCase();
if (!trig || trig === data.extraSettings.command) processCommands(content);
return;
}
}
);

/* ------------------------------------------------------------------ */
/* 6. Periodic drawing ideas / prompts */
/* ------------------------------------------------------------------ */
if (cfg.enableIdeas) {
const mins = Math.max(1, Number(cfg.ideaIntervalMinutes) || 5);

/** Each idea has:
* prompt – a short description
* cmds – ONE chat line (space- or ;-separated) ready to paste
* Co-ords assume 1920×1080 canvas; scale if you use another size
*/
const ideas = [
{
prompt: "Tiny yellow DUCK (3 squares):",
cmds: "!line 940 540 980 540 #ffff00 40 ; !line 940 580 980 580 #ffff00 40 ; !line 960 520 960 520 #000000 40",
},
{
prompt: "8-bit HEART:",
cmds: "!stroke 960,300 1040,380 1040,500 960,580 880,500 880,380 960,300 #ff66cc 12",
},
{
prompt: "POKÉ BALL:",
cmds: "!stroke 960,440 1120,540 960,640 800,540 960,440 #ffffff 10 !stroke 960,640 1120,540 960,440 800,540 960,640 #ff0000 10 !line 800 540 1120 540 #000000 12",
},
{
prompt: "Rainbow ARC with curves:",
cmds: "!curve 300 700 960 200 1620 700 #ff0000 12 !curve 300 760 960 260 1620 760 #ff7f00 12 !curve 300 820 960 320 1620 820 #ffff00 12",
},
{
prompt: "SMILEY face:",
cmds: "!stroke 960,300 1190,540 960,780 730,540 960,300 #ffff00 12 !stroke 860,460 900,460 900,500 860,500 860,460 #000000 10 !stroke 1020,460 1060,460 1060,500 1020,500 1020,460 #000000 10 !line 860 640 1060 640 #000000 10",
},
{
prompt: "Five-point STAR:",
cmds: "!stroke 960,200 1040,460 1300,460 1080,620 1160,880 960,720 760,880 840,620 620,460 880,460 960,200 #ffd700 8",
},
{
prompt: "TINY HOUSE:",
cmds: "!line 780 660 1140 660 #ffcc66 10 !line 780 660 780 880 #ffcc66 10 !line 1140 660 1140 880 #ffcc66 10 !line 780 880 1140 880 #ffcc66 10 !stroke 780,660 960,500 1140,660 #ff9966 10",
},
{
prompt: "COFFEE CUP with steam:",
cmds: "!line 900 700 1020 700 #a8734c 12 !line 900 700 900 780 #a8734c 12 !line 1020 700 1020 780 #a8734c 12 !line 900 780 1020 780 #a8734c 12 !curve 940 660 960 620 980 660 #ffffff 10 !curve 960 620 980 580 1000 620 #ffffff 10",
},
{
prompt: "LIGHTNING bolt (stroke):",
cmds: "!stroke 960,300 1040,500 880,500 1060,780 #ffff00 14",
},
{
prompt: "SPIRAL galaxy (3 curves):",
cmds: "!curve 960 540 1320 540 1320 200 #66ccff 10 !curve 960 540 600 540 600 880 #66ccff 10 !curve 960 540 1260 540 960 820 #66ccff 10",
},
{
prompt: "MARIO QUESTION BLOCK:",
cmds: "!line 860 400 1060 400 #ffcc00 40 !line 860 400 860 600 #ffcc00 40 !line 1060 400 1060 600 #ffcc00 40 !line 860 600 1060 600 #ffcc00 40 !line 920 480 1000 480 #ffffff 40 !line 920 480 920 520 #ffffff 40 !line 1000 480 1000 520 #ffffff 40 !line 920 520 1000 520 #ffffff 40",
},
{
prompt: "AMONG US crewmate:",
cmds: "!stroke 960,400 1180,400 1220,450 1220,820 700,820 700,450 740,400 960,400 #ff4141 12 !stroke 820,480 1040,480 1080,520 1080,620 780,620 780,520 820,480 #aeeffd 12 !stroke 1260,550 1340,550 1340,800 1260,800 1260,550 #ff4141 12",
},
{
prompt: "8-bit SWORD:",
cmds: "!line 960 300 960 780 #cccccc 40 !line 920 780 1000 780 #663300 40 !line 880 820 1040 820 #663300 40",
},
{
prompt: "CHERRY blossom branch (curves + lines):",
cmds: "!line 700 780 1220 780 #8b4513 12 !curve 800 760 850 700 900 760 #ffb6c1 12 !curve 1000 760 1050 700 1100 760 #ffb6c1 12",
},
{
prompt: "RAINBOW stripes (7 lines):",
cmds: "!line 0 200 1920 200 #ff0000 16 !line 0 240 1920 240 #ff7f00 16 !line 0 280 1920 280 #ffff00 16 !line 0 320 1920 320 #00ff00 16 !line 0 360 1920 360 #0000ff 16 !line 0 400 1920 400 #4b0082 16 !line 0 440 1920 440 #9400d3 16",
},
];

setInterval(() => {
const idea = ideas[Math.floor(Math.random() * ideas.length)];
Overlay.chatbot({ message: `[Art Canvas Idea] ${idea.prompt}\n${idea.cmds}` });
}, mins * 60_000);
}

HTML

<div id="board">
<canvas id="canvas"></canvas>
<ul id="chat-list"></ul>
</div>

CSS Styling

/* Make the whole overlay fill whatever size you give the browser-source */
html, body, #board {
width: 100%;
height: 100%;
margin: 0;
}

/* Canvas covers the full area and stays transparent */
#canvas {
width: 100%;
height: 100%;
display: block; /* removes stray inline-block gaps */
background: transparent; /* fully see-through */
touch-action: none;
}

/* Optional grid (still transparent behind the lines) */
#canvas.grid {
background-image:
repeating-linear-gradient(0deg , transparent 0 29px, #d0d0d055 30px),
repeating-linear-gradient(90deg, transparent 0 29px, #d0d0d055 30px);
}

/* Chat history can stay the same — stretches to overlay width */
#chat-list {
list-style: none;
margin: 0;
padding: 4px;
width: 100%;
max-height: 160px;
overflow-y: auto;
background: #ffffffc0; /* 75 % opaque white strip */
font-family: "Segoe UI", sans-serif;
font-size: 14px;
border: 1px solid #ccc;
}
#chat-list li { margin: 2px 0; }
.user { font-weight: 600; color: ; margin-right: 4px; }

Configs

{
"showStartupMessage": {
"type": "checkbox",
"label": "Send a chatbot help message when the overlay loads",
"order": 1,
"value": true
},
"allowModCommands": {
"type": "checkbox",
"label": "Enable moderator runtime commands",
"order": 2,
"value": true
},
"enableIdeas": {
"type": "checkbox",
"label": "Send periodic drawing ideas",
"order": 3,
"value": false
},
"ideaIntervalMinutes": {
"min": 1,
"type": "number",
"label": "Minutes between ideas",
"order": 4,
"value": 5,
"visibleIf": {
"key": "enableIdeas",
"equals": true
}
},
"allowChat": {
"type": "checkbox",
"label": "Enable chat commands",
"order": 5,
"value": true
},
"userLevels": {
"type": "multiselect",
"label": "Allowed chat user-levels",
"order": 6,
"value": [
"anyone"
],
"options": [
"anyone",
"mod",
"vip",
"subscriber",
"tier2",
"tier3",
"follower"
]
},
"allowBits": {
"type": "checkbox",
"label": "Enable Bits (Twitch Cheer)",
"order": 7,
"value": false
},
"minBits": {
"min": 1,
"type": "number",
"label": "Minimum Bits to accept",
"order": 8,
"value": 100,
"visibleIf": {
"key": "allowBits",
"equals": true
}
},
"allowTwitchPoints": {
"type": "checkbox",
"label": "Enable Twitch Channel-Points",
"order": 9,
"value": false
},
"twitchPointsCommand": {
"type": "input",
"label": "Twitch Points trigger name (Must also create the point command in Lumia with the same name)",
"order": 10,
"value": "art",
"visibleIf": {
"key": "allowTwitchPoints",
"equals": true
}
},
"allowKickPoints": {
"type": "checkbox",
"label": "Enable Kick Points",
"order": 11,
"value": false
},
"kickPointsCommand": {
"type": "input",
"label": "Kick Points trigger name (Must also create the point command in Lumia with the same name)",
"order": 12,
"value": "art",
"visibleIf": {
"key": "allowKickPoints",
"equals": true
}
}
}

Data

{
"minBits": 0,
"minBitss": 0,
"allowBits": true,
"allowChat": true,
"minBitss3": 0,
"allowLumia": true,
"userLevels": [
"anyone"
],
"enableIdeas": true,
"allowKickPoints": true,
"allowModCommands": true,
"allowTwitchPoints": true,
"kickPointsCommand": "art",
"showStartupMessage": true,
"ideaIntervalMinutes": 2,
"twitchPointsCommand": "art"
}

HFX Listener Banner

Reacts to Lumia HFX triggers. Flashes a banner showing who triggered the HFX, which command, and their message. Uses Overlay.on("hfx", ...) — the handler receives the raw HFX payload (no event.detail).

HTML

<div id="hfx-banner" class="hidden">
<img id="hfx-avatar" alt="" />
<div class="text">
<div id="hfx-user"></div>
<div id="hfx-command"></div>
<div id="hfx-message"></div>
</div>
</div>

CSS

body {
background: transparent;
font-family: "{{font}}";
color: {{textColor}};
}
#hfx-banner {
position: absolute;
bottom: 40px;
left: 40px;
display: flex;
align-items: center;
gap: 16px;
padding: 16px 24px;
background: {{bgColor}};
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
transform: translateX(-120%);
transition: transform 400ms cubic-bezier(0.2, 0.9, 0.3, 1);
}
#hfx-banner.show {
transform: translateX(0);
}
#hfx-banner.hidden {
display: none;
}
#hfx-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
background: #222;
object-fit: cover;
}
.text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: {{fontSize}}px;
}
#hfx-user {
font-weight: 700;
}
#hfx-command {
opacity: 0.8;
font-size: 0.8em;
}
#hfx-message {
opacity: 0.9;
font-style: italic;
}

JS

const banner = document.getElementById("hfx-banner");
const avatarEl = document.getElementById("hfx-avatar");
const userEl = document.getElementById("hfx-user");
const commandEl = document.getElementById("hfx-command");
const messageEl = document.getElementById("hfx-message");
let hideTimer = null;

function show(data) {
avatarEl.src = data.avatar || "https://storage.lumiastream.com/placeholderUserIcon.png";
userEl.textContent = data.username || "Someone";
commandEl.textContent = data.command ? `triggered ${data.command}` : "triggered an HFX";
messageEl.textContent = data.message || "";

banner.classList.remove("hidden");
// allow the DOM to register the hidden→visible transition
requestAnimationFrame(() => banner.classList.add("show"));

clearTimeout(hideTimer);
const holdMs = Math.max(2000, Number(data.duration) || Number(Overlay.data?.holdMs) || 5000);
hideTimer = setTimeout(() => {
banner.classList.remove("show");
setTimeout(() => banner.classList.add("hidden"), 500);
}, holdMs);
}

Overlay.on("hfx", (data) => {
show(data);
});

Configs

{
"bgColor": {
"type": "colorpicker",
"label": "Banner background",
"order": 1,
"value": "#1a1a2ecc"
},
"textColor": {
"type": "colorpicker",
"label": "Text color",
"order": 2,
"value": "#ffffff"
},
"font": {
"type": "fontpicker",
"label": "Font",
"order": 3,
"value": "Inter"
},
"fontSize": {
"type": "slider",
"label": "Font size",
"order": 4,
"options": { "min": 12, "max": 40, "step": 2, "suffix": "px" },
"value": 20
},
"holdMs": {
"type": "number",
"label": "Display time (ms) — used when HFX duration is missing",
"order": 5,
"value": 5000
}
}

Data

{
"bgColor": "#1a1a2ecc",
"textColor": "#ffffff",
"font": "Inter",
"fontSize": 20,
"holdMs": 5000
}

Virtual Light Monitor

Shows an on-screen representation of a Lumia virtual light: a colored dot that matches the current color, brightness, and power state. Uses Overlay.on("virtuallight", ...). Also demonstrates the first-load storage pattern so the last-known color persists across overlay reloads.

HTML

<div id="wrap">
<div id="dot"></div>
<div id="label"></div>
</div>

CSS

body {
background: transparent;
font-family: "{{font}}";
color: #ffffff;
}
#wrap {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 20px;
background: rgba(0, 0, 0, 0.55);
border-radius: 999px;
width: fit-content;
}
#dot {
width: 36px;
height: 36px;
border-radius: 50%;
background: #000;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12);
transition: background 200ms linear, opacity 200ms linear, transform 200ms ease;
}
#dot.off {
opacity: 0.25;
transform: scale(0.85);
}
#label {
font-size: 16px;
white-space: nowrap;
}

JS

const dot = document.getElementById("dot");
const label = document.getElementById("label");
const STORAGE_KEY = "last_light_state";

// Default state (first load)
let state = {
uuid: "",
r: 0,
g: 0,
b: 0,
brightness: 100,
power: true,
};

// Seed storage on first load so we never see the red "no value" toast
const saved = await Overlay.getStorage(STORAGE_KEY);
if (saved == null) {
await Overlay.saveStorage(STORAGE_KEY, state);
} else {
state = saved;
}
render();

function render() {
const { r, g, b, brightness, power, uuid } = state;
dot.style.background = `rgb(${r}, ${g}, ${b})`;
dot.classList.toggle("off", power === false);
const hex = `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
const name = uuid || "No light yet";
label.textContent = power === false ? `${name} — off` : `${name}${hex} @ ${brightness}%`;
}

Overlay.on("virtuallight", async (data) => {
state = {
uuid: data.uuid || state.uuid,
r: data.color?.r ?? state.r,
g: data.color?.g ?? state.g,
b: data.color?.b ?? state.b,
brightness: data.brightness ?? state.brightness,
power: data.power !== false,
};
render();
await Overlay.saveStorage(STORAGE_KEY, state);
});

Configs

{
"font": {
"type": "fontpicker",
"label": "Font",
"order": 1,
"value": "Inter"
}
}

Data

{
"font": "Inter"
}

Loyalty Points Leaderboard

Uses Overlay.getLoyaltyPoints + Overlay.addLoyaltyPoints. Chat commands:

  • !points — the user's current balance is posted back to chat via Overlay.chatbot.
  • !give @user <n> — transfer N points from the sender to @user (moderator-only).

Also renders a top-10 leaderboard from a locally cached list of users who have been seen in chat (loyalty points themselves are managed by Lumia — we just display the cache). Cache is persisted with Overlay.saveStorage.

HTML

<div id="board">
<h2>Top Loyalty</h2>
<ol id="list"></ol>
</div>

CSS

body {
background: transparent;
font-family: "{{font}}";
color: #ffffff;
}
#board {
width: 360px;
padding: 20px 24px;
background: rgba(10, 10, 20, 0.85);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
#board h2 {
margin: 0 0 12px;
font-size: 20px;
color: {{accentColor}};
}
#list {
margin: 0;
padding: 0 0 0 24px;
font-size: 16px;
line-height: 1.6;
}
#list li .amount {
float: right;
font-weight: 700;
color: {{accentColor}};
}

JS

const listEl = document.getElementById("list");
const STORAGE_KEY = "loyalty_seen_users";

// first-load seed
let seen = await Overlay.getStorage(STORAGE_KEY);
if (seen == null) {
seen = [];
await Overlay.saveStorage(STORAGE_KEY, seen);
}

async function refresh() {
const rows = [];
for (const u of seen) {
const pts = Number(await Overlay.getLoyaltyPoints({ username: u.username, platform: u.platform })) || 0;
rows.push({ ...u, pts });
}
rows.sort((a, b) => b.pts - a.pts);

listEl.textContent = "";
rows.slice(0, 10).forEach((r) => {
const li = document.createElement("li");
const name = document.createElement("span");
name.textContent = r.username;
const amt = document.createElement("span");
amt.className = "amount";
amt.textContent = r.pts.toLocaleString();
li.append(name, amt);
listEl.appendChild(li);
});
}

async function remember(username, platform) {
if (!username || !platform) return;
const existing = seen.find((u) => u.username === username && u.platform === platform);
if (existing) return;
seen.push({ username, platform });
await Overlay.saveStorage(STORAGE_KEY, seen);
}

Overlay.on("chat", async (data) => {
const msg = (data.message || "").trim();
const username = data.username;
const platform = data.platform || "twitch";
const isMod = data.userLevels?.mod || data.userLevels?.isSelf;

await remember(username, platform);

// !points — look up caller's balance
if (msg.toLowerCase() === "!points") {
const pts = Number(await Overlay.getLoyaltyPoints({ username, platform })) || 0;
await Overlay.chatbot({ message: `${username} has ${pts} points`, platform });
await refresh();
return;
}

// !give @user 50 — moderator transfer
if (msg.toLowerCase().startsWith("!give ") && isMod) {
const parts = msg.split(/\s+/);
const target = (parts[1] || "").replace(/^@/, "");
const amount = Math.max(0, Math.floor(Number(parts[2]) || 0));
if (!target || !amount) {
await Overlay.chatbot({ message: "Usage: !give @user 50", platform });
return;
}
await Overlay.addLoyaltyPoints({ value: -amount, username, platform });
await Overlay.addLoyaltyPoints({ value: amount, username: target, platform });
await remember(target, platform);
await Overlay.chatbot({ message: `${username} gave ${amount} points to ${target}`, platform });
await refresh();
}
});

// Refresh on load and every 30s so balances stay current
await refresh();
setInterval(refresh, 30000);

Configs

{
"accentColor": {
"type": "colorpicker",
"label": "Accent color",
"order": 1,
"value": "#ffcc00"
},
"font": {
"type": "fontpicker",
"label": "Font",
"order": 2,
"value": "Inter"
}
}

Data

{
"accentColor": "#ffcc00",
"font": "Inter"
}