Skip to content

How to create a widget for streams with remote control

In this article, we’ll go step by step to create a stream widget with remote control. You can follow along and end up with a working widget.

Preparation

All widgets are regular websites that run in the built-in browser of OBS Studio. Therefore, to create a widget you’ll need a standard web-development toolset.

To keep things simple, we won’t use any web frameworks and will implement everything with plain HTML, CSS, and JavaScript. However, we’ll still use Vite (a tool that simplifies development and bundling for publication).

BunJS

Install BunJS — a modern package manager and JavaScript runtime. You can find installation instructions on the official website.

powershell
powershell -c "irm bun.sh/install.ps1 | iex"
bash
curl -fsSL https://bun.sh/install | bash

Visual Studio Code

You’ll also need VSCode — the code editor we’ll use for development. Download it from the official website.

Project creation

Create a new folder for your project and open it in VSCode. Then open the terminal in VSCode (TerminalNew Terminal) and run:

bash
bun create vite . --template vanilla

Press Enter a few times to accept the defaults.

Terminal output after running the command

vite-create-terminal-output

Your project will be set up, dependencies installed, and a local development server started (usually at http://localhost:5174/).

Open that address in your browser—you should see the Hello Vite! starter page.

Clean up the starter page: in VSCode, delete the public folder and the files src/counter.js and src/javascript.svg.

Edit the src/main.js file, removing everything except the stylesheet import:

src/main.js
javascript
import './style.css'

Also completely clear the src/style.css file, leaving it empty.

Done! Thanks to Vite, every change in your files will be automatically reflected in the browser. You should now see a blank white page.

Creating the widget

Development will consist of two stages:

  • Create the widget’s visual design
  • Add remote-control functionality

Visual design of the widget

The widget’s design is completely up to you and depends on your task; the process is no different from building a regular website. In this article, we’ll create a simple LBZ (Personal Missions) counter. It consists of three blocks, each with a progress bar and the number of completed LBZ.

The only difference from a regular website is the widget’s dimensions. We’ll make it automatically fit the screen width so the streamer can use it at any resolution. To do this, we’ll set the font size to 1% of the viewport width and specify all sizes in em.

src/style.css
css
:root {
  /* Transparent background */
  color-scheme: dark;
  background: transparent;
}

body {
  /* Set font size based on window width */
  font-size: 1vw;

  /* Remove margin and hide scrollbars */
  margin: 0;
  overflow: hidden;
}

Now create the general structure of the widget in index.html:

index.html
html
...
<div id="app">
  <div class="item item-1">
    <header>
      <h2>VI - VII уровень</h2>
      <p><span class="current">18</span> / 25</p>
      <img src="./src/assets/complete.png">
    </header>
    <div class="progress-bar">
      <div class="progress-bar-background"></div>
      <div class="progress-bar-pattern"></div>
      <div class="progress-bar-blink"></div>
    </div>
  </div>
</div>
...

Duplicate the div.item block twice, change the h2 heading text, and add the completed class to the last div.item.

We’ll need some assets from the game, which you can find in the gui.pkg package or in the Kurzdor/wot.assets repository.

Copy the required assets into the src/assets folder of your project.

Adjust the styles in style.css to achieve the following result:

Full style.css source
css
:root {
  /* Transparent background */
  color-scheme: dark;
  background: transparent;
}

@font-face {
  font-family: Warhelios;
  src: url(./assets/Warhelios-Regular.ttf) format("truetype");
}

body {
  /* Set font size based on window width */
  font-size: 1vw;

  /* Remove margin and hide scrollbars */
  margin: 0;
  overflow: hidden;

  font-family: Warhelios, sans-serif;
}

#app {
  display: flex;
  gap: 1em;
  padding: 1em;
}

.item {
  border-radius: 1.3em;
  padding: 1.2em;
  border: 0.25em solid rgba(255, 173, 65, 0.4);
  background:
    linear-gradient(0deg, rgba(195, 90, 4, 0.1), rgba(255, 255, 255, 0) 50%),
    radial-gradient(farthest-corner at 50% 200%, rgba(251, 166, 20, 0.4), rgba(218, 139, 1, 0.3) 90%);

  display: flex;
  flex-direction: column;
  flex: 1;

  max-width: 30%;
  box-shadow: 0 0 1em rgba(0, 0, 0, 0.2);
}

.completed {
  border: 0.25em solid rgba(106, 255, 65, 0.3);
  background:
    linear-gradient(0deg, rgba(80, 255, 27, 0.3), rgba(26, 255, 0, 0) 50%),
    radial-gradient(farthest-corner at 50% 200%, rgba(4, 102, 5, 0.4), rgba(13, 114, 0, 0.3) 90%);
}

header {
  font-size: 1em;
  display: flex;
}

header h2 {
  flex: 1;
}

header h2,
header p {
  font-size: 2.7em;
  margin: 0;
  font-weight: normal;
}

.current {
  color: #ffd28f;
}

img {
  width: 9em;
  height: 9em;
  margin: -3em;
  display: none;
}

/* Progress bar */
.progress-bar {
  height: 0.3em;
  width: 100%;
  margin-top: 1em;
  position: relative;
}

/* Background part of the progress bar (gray rectangles) */
.progress-bar-background {
  background-image: url(./assets/pattern_grey.png);
  background-repeat: repeat;
  background-position: 0 50%;
  background-size: 0.4em 1em;

  position: absolute;
  width: 100%;
  height: 100%;
  box-shadow: 0 0 2em rgba(0, 0, 0, 0.5);
}

/* Current progress highlight (glow) */
.progress-bar-blink {
  height: 5em;
  width: 5em;

  background-image: url(./assets//glow_small.png);
  background-size: contain;
  mix-blend-mode: lighten;
  position: absolute;
  transform: translate(-49%, -50%);
  top: 50%;
  left: var(--progress, 60%);
  transition: left 0.2s ease;
}

/* Filled part of the progress bar */
.progress-bar-pattern {
  background-image: url(./assets/pattern_orange.png);
  background-repeat: repeat;
  background-position: 0 50%;
  background-size: 0.4em 1em;

  position: absolute;
  height: 100%;
  width: var(--progress, 60%);
  transition: width 0.2s ease;
}

/* Gradient highlight for the progress bar */
.progress-bar-pattern::after {
  content: "";
  position: absolute;
  right: 0;
  top: 0;
  width: 100%;
  height: 100%;

  background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(255, 206, 122, 0.5));
  mix-blend-mode: overlay;
}

/* Overrides for completed stages */
.completed .progress-bar-pattern {
  background-image: url(./assets/pattern_green.png);
  width: 100%;
}

.completed .progress-bar-pattern::after {
  background-image: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(187, 255, 78, 0.519), rgba(0, 0, 0, 0.6));
}

.completed .progress-bar-blink,
.completed header p {
  display: none;
}

.completed img {
  display: block;
}
Full index.html source
html
<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>article-pm-widget</title>
</head>

<body>
  <div id="app">
    <div class="item item-1">
      <header>
        <h2>VI - VII tire</h2>
        <p><span class="current">18</span> / 25</p>
        <img src="./src/assets/complete.png">
      </header>
      <div class="progress-bar">
        <div class="progress-bar-background"></div>
        <div class="progress-bar-pattern"></div>
        <div class="progress-bar-blink"></div>
      </div>
    </div>

    <div class="item item-2">
      <header>
        <h2>VII - IX tire</h2>
        <p><span class="current">18</span> / 25</p>
        <img src="./src/assets/complete.png">
      </header>
      <div class="progress-bar">
        <div class="progress-bar-background"></div>
        <div class="progress-bar-pattern"></div>
        <div class="progress-bar-blink"></div>
      </div>
    </div>

    <div class="item item-3 completed">
      <header>
        <h2>X - XI tire</h2>
        <p><span class="current">18</span> / 25</p>
        <img src="./src/assets/complete.png">
      </header>
      <div class="progress-bar">
        <div class="progress-bar-background"></div>
        <div class="progress-bar-pattern"></div>
        <div class="progress-bar-blink"></div>
      </div>
    </div>

    <script type="module" src="/src/main.js"></script>
</body>

</html>

As a result, you’ll get the following widget: widget-prepare-result.png

Remote-control functionality

Now let’s add remote control. To do this, install the wotstat-widgets-sdk library and declare the widget’s remote parameters.

Run this command in the terminal:

powershell
bun add wotstat-widgets-sdk
bash
bun add wotstat-widgets-sdk

In the src/main.js file, import the library and declare the widget parameters:

src/main.js
javascript
import './style.css'
import { WidgetsRemote } from 'wotstat-widgets-sdk';

const remote = new WidgetsRemote();

remote.defineState('VI - VII уровень', 0, { elementHelper: '.item-1' });
remote.defineState('VII - IX уровень', 0, { elementHelper: '.item-2' });
remote.defineState('X - XI уровень', 0, { elementHelper: '.item-3' });

Now you can open your link in the widget control panel at ru.widgets.wotstat.info/remote-control and make sure the widget parameters appear in the list; when you hover over a parameter, the corresponding widget block is outlined: widget-remote-control-params.png

The outline works thanks to the elementHelper property, where we specified the CSS selector of the desired block.

Next, we need to handle parameter changes. Create an updateState function and subscribe to changes for each parameter using the watch method:

src/main.js
javascript
...
function updateState(selector, value) {
  const element = document.querySelector(selector);

  // Set the counter value
  element.querySelector('.current').textContent = value;

  // Toggle the completed stage class
  if (value >= 25) element.classList.add('completed');
  else element.classList.remove('completed');

  // Add a CSS variable for the progress bar
  element.style.setProperty('--progress', `${100 * value / 25}%`);
}

remote.defineState('VI - VII уровень', 0, { elementHelper: '.item-1' })
  .watch((v) => updateState('.item-1', v));

remote.defineState('VII - IX уровень', 0, { elementHelper: '.item-2' })
  .watch((v) => updateState('.item-2', v));

remote.defineState('X - XI уровень', 0, { elementHelper: '.item-3' })
  .watch((v) => updateState('.item-3', v));

Done! Now, when the parameters change in the control panel, the widget updates automatically.

Publishing the widget

The final step is to publish the widget so it’s available for use over the internet.

First, build the project. In the terminal, run:

powershell
bun run build
bash
bun run build

As a result, a dist folder will be created containing the site ready for publication.

terminal-build-output

You can host this site on any static hosting, for example:

  • wasmer.io — free after registration (you can sign in with Google)
  • Yandex Cloud in S3 hosting mode — free, but registration is complicated
  • GitHub Pages — free, but requires registration and creating a repository on GitHub

Adding to OBS

After publishing, paste the widget link into the remote control panel, generate an access key, and add the widget to OBS Studio as a Browser Source, specifying the generated link.

Don’t forget to choose Blending MethodSRGB off. You can study this in more detail in the guide.

Source code

All widget source code is available in the repository on GitHub. The result is published on GitHub Pages at: soprachevak.github.io/mt-pm3-progress-widget and in the control panel at ru.widgets.wotstat.info/remote-control

Contributors

Changelog