Skip to content

Instantly share code, notes, and snippets.

@SH20RAJ
Created December 18, 2025 04:41
Show Gist options
  • Select an option

  • Save SH20RAJ/1852795ed4d341b346a54db3bfdb35b4 to your computer and use it in GitHub Desktop.

Select an option

Save SH20RAJ/1852795ed4d341b346a54db3bfdb35b4 to your computer and use it in GitHub Desktop.
StreamElements Custom Widget Development Guide

StreamElements Custom Widget Development Guide

This guide documents the architecture and tactics used to build the Unified Goal Widget. It demonstrates how to create a robust, production-ready widget that runs seamlessly in both a Local Development Environment and the StreamElements OBS/Browser Source using a single codebase.

1. The "Hybrid" Architecture

The core philosophy is strict separation of Logic (JS), Presentation (CSS/SVG), and Configuration (JSON), bridged by an Environment Adapter.

The Problem

StreamElements widgets normally require being uploaded to the dashboard to test functionality. This feedback loop is very slow.

The Solution (One File Strategy)

We detect the runtime environment. If the global SE_API object is missing, we assume we are running locally and inject a Mock Layer that simulates StreamElements behavior.

/* widget.js Pattern */
(function() {
    // 1. Detect Environment
    if (typeof SE_API !== 'undefined') {
        // Production: Attach real listeners
        window.addEventListener('onWidgetLoad', (obj) => init(obj.detail.fieldData, obj.detail.session.data));
        window.addEventListener('onEventReceived', (obj) => onEvent(obj.detail));
    } else {
        // Local Dev: Inject Mocks
        window.isDev = true;
        mockStreamElementsEnvironment();
    }
})();

2. Connecting StreamElements APIs

We rely on three specific interaction points.

A. Configuration (fields.json)

Defines the UI controls in the StreamElements dashboard (left sidebar).

  • Tactic: Group related fields using the "group" property (e.g., "Colors", "Behavior").
  • Tactic: Use distinct types (colorpicker, image-input, slider, dropdown) to create a professional UX.
  • Tactic: Provide rational value defaults so the widget looks good immediately upon loading.

B. Initialization (onWidgetLoad)

Called once when the widget starts or when settings change.

  • Payload: Contains user settings (fieldData) and session data (data).
  • Tactic: Strict Defaulting. StreamElements might return undefined for new fields. Always merge with a default object:
    settings = { ...defaults, ...fieldData };
  • Tactic: Type Safety. Convert string inputs to numbers immediately using parseFloat() to prevent NaN errors later.

C. Event Loop (onEventReceived)

Called whenever a Twitch/YouTube event (Follow, Sub, Tip) occurs.

  • Tactic: Early Return. Filter events immediately if they don't match the listener you care about.
  • Tactic: Unified Handlers. Route different event types (Tip, Cheer, Sub) to a single updateProgress(amount) function to keep logic clean.

3. The Local Development Mock

To make the widget work locally without changing code, we simulate the environment at the bottom of widget.js.

Features of the Mock:

  1. Simulated Store: A dummy SE_API.store object that implements get and set (returning Promises) so the main code doesn't crash.
  2. Dev UI: An HTML overlay (<div id="dev-controls">) that injects fake events when buttons are clicked.
  3. Auto-Init: We manually call the init() function with a preset configuration object after 100ms, mimicking the boot sequence of the real widget.

4. Best Practices Used

🎨 SVG Geometry for Shapes

Instead of using static images or CSS border-radius hacks, we used SVG Paths with stroke-dasharray.

  • Why: Infinite scalability, zero pixelation, and performance.
  • The Power Move (describeArc): A single mathematical function calculates the SVG path command (d="M... A...") dynamically based on start and end angles.
    • This allowed us to reuse the exact same code for "Full Circle", "Arch", "Horseshoe", and "Wide Arc" just by changing the angles passed to the function.

πŸ’Ύ Persistence (SE_API.store)

We use SE_API.store.set('key', value) to save the current goal progress.

  • Why: Browser sources in OBS refresh frequently. Without persistence, the goal resets to 0 every time the streamer restarts OBS.
  • Key Isolation: Use unique keys (e.g., goal_unified_current) to avoid conflicts if the user runs multiple widgets.

⚑ CSS Variables for Theming

We map fields.json settings directly to CSS variables (--primary, --size, --font).

  • Benefit: changing a color in the settings updates the DOM instantly via the style attribute on the root, without needing to query selectors or force expensive style recalculations on individual elements.

5. File Structure

This structure allows you to zip the contents and upload/copy-paste effortlessly.

/unified-widget
  β”œβ”€β”€ widget.html   (Structure + Hidden Dev UI)
  β”œβ”€β”€ widget.css    (Styles + CSS Vars + Dev UI Styles)
  β”œβ”€β”€ widget.js     (Logic + Mock Adapter)
  └── fields.json   (Schema configuration)
@SH20RAJ
Copy link
Author

SH20RAJ commented Dec 18, 2025

StreamElements Custom Widget Development Masterclass

This comprehensive guide documents the architecture, tactics, and best practices used to build the Unified Goal Widget. It serves as a blueprint for creating robust, production-ready widgets that work seamlessly in both Local Development Environments and StreamElements OBS/Browser Sources.


πŸ—οΈ 1. Architecture: The "Hybrid" Bridge Pattern

The core challenge in StreamElements development is the feedback loop. Uploading code to the dashboard to test every small change is inefficient. We solve this using a Hybrid Architecture that abstracts the environment.

The Problem

  • Production: Runs in an OBS Browser Source. Receives data via window.onEventReceived. Has a global SE_API object.
  • Development: Runs in localhost. Has no event stream. Has no SE_API.

The Solution

We detect the runtime environment and inject a Mock Adaptor if we are running locally. This allows the exact same business logic to run in both places without modification.

Code Pattern

(function() {
    // 1. Detection strategy
    const isProduction = typeof SE_API !== 'undefined';

    if (isProduction) {
        // PRODUCTION: Attach to real StreamElements event bus
        window.addEventListener('onWidgetLoad', (obj) => init(obj.detail.fieldData, obj.detail.session.data));
        window.addEventListener('onEventReceived', (obj) => onEvent(obj.detail));
    } else {
        // DEVELOPMENT: Boot the Simulator
        console.log("πŸ› οΈ Starting Local Dev Environment");
        window.isDev = true;
        
        // 1. Mock the API
        window.SE_API = { 
            store: { 
                get: (key) => Promise.resolve(localStorage.getItem(key)), // Use LocalStorage for persist testing
                set: (key, val) => localStorage.setItem(key, val) 
            } 
        };
        
        // 2. Inject Dev UI
        injectDevControls(); // Spawns the buttons on screen
        
        // 3. Auto-Boot
        setTimeout(() => {
            // Check fields.json for your default values and replicate them here
            init({
                goalValue: 100,
                primaryColor: '#ff0000',
                // ... all other defaults
            }, {});
        }, 100);
    }
})();

πŸŽ›οΈ 2. Mastering fields.json

The fields.json file is the contract between your code and the user. It generates the configuration UI in the StreamElements dashboard.

Field Types Reference

Type Description JSON Example
header Visual separator {"type": "header", "label": "Colors"}
text String input {"type": "text", "label": "Title", "value": "GOAL"}
number Numeric input {"type": "number", "step": 1, "min": 0, "max": 100}
checkbox Boolean toggle {"type": "checkbox", "label": "Enable Sound"}
colorpicker Hex color selector {"type": "colorpicker", "value": "#ff0000"}
dropdown Select list {"type": "dropdown", "options": {"key": "Label"}}
slider Range slider {"type": "slider", "min": 0, "max": 100, "step": 1}
image-input File uploader {"type": "image-input", "label": "Custom Icon"}
googleFont Font selector {"type": "googleFont", "label": "Font Family"}

Best Practices

  1. Grouping: Use the "group" property on every field to organize them into tabs (e.g., Settings, Style, Data).
  2. Validation: Always assume the user might enter bad data (e.g., text in a number field). Sanitize in JS:
    // Robust parsing
    const target = parseFloat(settings.goalValue) || 100; // Fallback prevents crash

πŸ“ 3. The SVG Engine: describeArc

Direct CSS manipulation is limited for complex shapes. We use SVG paths because they are vector-based (crisp at any resolution) and mathematically manipulatable.

The Algorithm

To draw any circular segment (Circle, Arch, Horseshoe), we use a helper that converts Polar Coordinates (Angles) to Cartesian Coordinates (X, Y).

function describeArc(x, y, radius, startAngle, endAngle) {
    // 1. Convert angles to X,Y points on the circle
    const start = polarToCartesian(x, y, radius, endAngle);
    const end = polarToCartesian(x, y, radius, startAngle);
    
    // 2. Determine if arc is > 180 degrees (requires largeArcFlag=1)
    const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
    
    // 3. Build SVG Path Command
    // M = Move to start
    // A = Arc to end (Radius X, Radius Y, Rotation, LargeArc, Sweep, EndX, EndY)
    return [
        "M", start.x, start.y, 
        "A", radius, radius, 0, largeArcFlag, 1, end.x, end.y // "1" = Clockwise
    ].join(" ");
}

Shape Recipes

By simply changing the angles passed to this function, we create different widgets:

  • Full Circle: 0Β° to 359.9Β°
  • Upright Arch: 270Β° (Left) to 90Β° (Right) (+360 for calculation = 450Β°)
  • Horseshoe: 210Β° to 510Β° (300Β° total span)

πŸ’Ύ 4. State & Persistence

Browser sources in OBS are ephemeral. If the user closes OBS or hides the scene, the memory is cleared. You must persist data.

The SE_API.store

StreamElements provides a cloud key-value store.

  • Get: SE_API.store.get('my_key_name').then(val => { ... })
  • Set: SE_API.store.set('my_key_name', value)

Strategy

  1. On Load: Fetch the value. If null, use the startingValue from fields.json.
  2. On Update: Immediately .set() the new value.
  3. Key Namescoping: Use unique keys (e.g., goal_unified_v1) to prevent collisions with other widgets I might have.

⚑ 5. CSS Architecture

We use CSS Custom Properties (Variables) to make the styling entirely data-driven.

The Variables

:root {
  --primary: #ff0000;
  --size: 400px;
  --font: 'Poppins';
}

The JS Bridge

In widget.js, applyStyles() maps the JSON settings to these variables:

const r = document.documentElement;
r.style.setProperty('--primary', settings.primaryColor);
r.style.setProperty('--size', settings.widgetSize + 'px');

This allows for instant, repaint-free updates. Changing the size in the JS simply updates one number, and the entire widget (CSS layout, SVG dimensions) scales relative to that variable.


🚨 6. Troubleshooting Guide

Issue Cause Fix
"NaN" displayed fields.json value was empty or invalid. Use `parseFloat()
Path Invisible stroke-dashoffset set to full length. Initialize background tracks with offset: 0.
Image Blocks Text Z-Index stacking order. Set Image z-index: 1, Text z-index: 2 in CSS.
Font not changing Google Font not loaded. JS must create <link> tag dynamically for settings.fontFamily.
Layout Overlap Absolute positioning without specific bounds. Use specific class overrides (e.g., .mode-semi) to adjust top/bottom spacing.

πŸš€ 7. Deployment Checklist

Before handing off code:

  1. Reset Defaults: Ensure widget.js defaults match fields.json defaults.
  2. Remove Console Logs: Keep the prod output clean (except for errors).
  3. Verify Mock Removal: Ensure the dev controls are hidden by default via CSS (.hidden { display: none }).
  4. Test Zero States: What happens if the goal is 0? Or 100/100?
  5. Upload: Copy content of all 4 files (.html, .css, .js, .json) into the StreamElements Editor fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment