Starship Horizons
Log In
Knowledge BaseWriting A Widget

Writing A Widget

This is the practical companion to Widgets. Read that first for the concepts; this is the code.

A complete widget

Save as /js/widgets/heading.js:

import { Widget } from "/js/widgets/widgets.js";

export default class Heading extends Widget {

    constructor(options = {}) {
        super(options);

        let sourceID = options.id != null ? options.id : "";

        this.Name     = "Heading: #" + sourceID;
        this.parentID = "#" + sourceID;
        this.Parent   = document.getElementById(sourceID);

        this.Subscribe("reset",  (m) => { this.Render(); });
        this.Subscribe("vessel", (m) => { this.Render(); });
    }

    Render() {
        SetHtml(this.parentID, thisvessel.HeadingVerbose);
    }
}

Declare it in a console page:

<hz-widget id="heading" source="heading" class="panel-header"></hz-widget>

That is the whole contract. The default export is the constructor, the source attribute names the file, and options carries every attribute from the declaration.

Anatomy

  • super(options) first. Pass the options through — the base needs them, and it registers the widget as under construction so a later throw can still be cleaned up.
  • this.Name is how workbench.Widgets.Find() will locate this instance. Include the element id; that is the convention and it makes duplicates visible.
  • this.Parent is the element to render into. The controller has already created it by the time the constructor runs.
  • this.Subscribe, not game.Subscribe. This is the rule that matters most.

Adding server data

If your widget needs something the console does not already receive, claim the packet and subscribe to the topic it raises:

this.Subscribe("ready", (m) => {
    this.AcceptPacket("DAMAGE-TEAMS");
});

this.Subscribe("vessel-damage", (m) => { this.Render(); });

Claim on ready rather than in the constructor when you need the socket to exist. And claim through this.AcceptPacket so the claim is released when the widget is — the global does not track owners for you. See Packets And Topics.

Using a template

Widgets that render repeated rows load an HTML template rather than building strings inline:

this.layout = options.layout || "/templates/damage-listing.htm";
GetContent(this.layout).then((data) => {
    this.template = data;
    this.Render();
});

Templates use [Name]‑style placeholders that the widget replaces. Letting the layout be overridden through options.layout costs nothing and lets a module restyle your widget without replacing it.

Drawing in 3D or on a canvas

Ask for a canvas in the declaration:

<hz-widget id="radar" source="radar-3d" element="canvas" class="panel-full"></hz-widget>

three.js is available as a bare import, through the page's import map:

import * as THREE from "three";

A widget with its own render loop must stop it in Dispose(), and must not create a second one on reset.

Cleaning up

Dispose() {
    if (this._raf != null) {
        cancelAnimationFrame(this._raf);
        this._raf = null;
    }
    // ... release GPU objects, detach listeners ...
    super.Dispose();     // <- required
}

The super.Dispose() call is what releases subscriptions and packets. Forgetting it is a silent leak, and copying the base's body instead of calling it is the same mistake with more typing.

Checklist

  1. Does every subscription go through this.Subscribe?
  2. Does every packet claim go through this.AcceptPacket?
  3. If Dispose() is overridden, does it call super.Dispose()?
  4. Is everything the constructor does safe to do twice? Reset re‑runs initialisation on a page that is already running. This is where most widget bugs are.
  5. Is the element id unique on the page? Two declarations with one id mount two widgets against one element.
  6. Does it survive thisvessel being null? It will be, before assignment and after a reset.
  7. Does it do work when nothing has changed? A widget re‑rendering on every contact update in a busy mission will cost more than the entire 3D view.

Shipping it

Put the file at Html/js/widgets/<source>.js inside your module. Module files layer over the base game's by path, so a module can add new widgets and also replace existing ones. See Modules.

To make it appear on a console without editing that console, declare it from a component, a vessel class or a mission. That is the intended route, and it is what makes a widget content rather than a code change.

Debugging

The widget controller logs each widget as it mounts, and warns about duplicate ids. From the browser console, workbench.Widgets.Instances lists everything mounted and workbench.Widgets.Find("...") gets you a live handle to poke at. Import failures are reported as errors — if a widget simply never appears, that is the first place to look.

Last updated 5 September 2026