Examples
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.
Selected alert: crowdcontrol-effect on crowdcontrol
Message template: {{username}} sent {{effect}}
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: 5fromLumia: truedynamic {1}
value: "Big Light"{
"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 fullOverlay.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.alertfor donations, subs, follows, bits, raids. - Custom Chat Box — renders chat messages with DOM APIs (no
innerHTML).
Commands & games
- Roll a Dice —
!rollchat command with animation. - Pokemon Catch Mini-Game Overlay — full game with storage, chatbot, and SFX.
- Calculator with overlaySendCustomContent from Lumia Stream — receives content from a Lumia command via
overlaycontentlistener.
External APIs (fetch)
HFX / lights / loyalty
- HFX Listener Banner —
Overlay.on("hfx", ...)triggers an animated banner with the user, command, and message. - Virtual Light Monitor —
Overlay.on("virtuallight", ...)shows the current light color/brightness/power; persists across reloads withsaveStorage+ first-load null-check. - Loyalty Points Leaderboard —
Overlay.getLoyaltyPoints/addLoyaltyPoints+Overlay.chatbotfor!pointsand!givecommands.
Visual
- Art Canvas — canvas rendering.
- Font Picker with Quoted Variable — small reference for how Google fonts must be quoted in CSS.
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: <kbd>S</kbd> Spawn • <kbd>⇧S</kbd> Shiny • <kbd>R</kbd> Rare • <kbd>C</kbd> Catch • <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 viaOverlay.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"
}