Starship Horizons
Log In
☰
Knowledge Base›Writing 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("vessel-damage", (m) => { this.Render(); });

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

Subscribe to the topic before you claim the packet. If another widget already holds it, your claim is answered on the spot by replaying the packet's topics, and a widget that has not subscribed yet misses them. Claiming in a ready handler, as here, is the easy way to get the order right: it runs once the constructor has finished. You do not need to wait for the socket; a claim made before it opens is sent when it connects. 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.

Check what a packet actually raises before you rely on it; the mapping is in ProcessCMD in hydra.js. Not every packet raises a topic: DAMAGE-TEAMS, for instance, updates thisvessel.DamageTeams and announces nothing.

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.

The server does not touch these files. A server token such as [Server.IP] or [Screen.Title] written into a template or a widget layout appears on screen exactly as written. Leave the element empty and fill it in when you render, from the page's globals: swversion for the build version, screenTitle for the screen's display name.

A layout can hold hz-widget elements of its own. After writing it into your element, call this.Controller.Update() to mount them. If your element may already hold widgets that another widget put there, call this.Controller?.RemoveNested?.(this) before you write, and if the layout arrives asynchronously, check that your widget was not disposed while it was on its way:

GetContent(this.layout).then((data) => {
    if (this.Disposed)
        return;
    this.Controller?.RemoveNested?.(this);
    SetHtml(this.parentID, data);
    this.Controller?.Update();
});

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. A loop that queues itself should also check this.Disposed at the top of every frame, which the base class sets before anything else in Dispose():

Render() {
    if (this.Disposed)
        return;
    // ... draw ...
    this._raf = requestAnimationFrame(() => this.Render());
}

Showing what an object is

Do not write your own table of object types and icons. What an object looks like on any display is answered in one place, /js/object-icons.js, which the radars and the object lists all use:

import { ResolveIcon, IconStyleFor } from "/js/object-icons.js";

const icon = ResolveIcon(contact);   // { mode, src, tint, color }

// A list row: the .item-glyph box draws the same picture the radar does.
html += "<div class='item-glyph' style=\"" + IconStyleFor(contact) + "\"></div>";

It reads two things about an object. BaseType is its kind (Vessel, Model, Missile and so on) and Classification is what it is within that kind. A station, a container or a drone arrives as a Vessel or an Object by kind, so a switch on BaseType draws every one of them as a ship. The same goes for a filter: to ask "is this a station?", ask Classification.

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.

Adding your own settings

A widget or a screen can add its own pane to the console's Settings panel without touching the panel. Every time Settings opens it raises settings-render; answer it by asking for a pane and filling the element you are handed. When the player presses SET on any added pane, Settings raises settings-save, with the pane's id as the value:

this.Subscribe("settings-render", (m) => {
    const body = m.value.AddPane({ id: "my-widget", title: "My Widget", sub: "What these do" });
    if (body != null)
        body.innerHTML = "...settings-card markup, filled from what you stored...";
});

this.Subscribe("settings-save", (m) => {
    // read your controls and store the values
});

What the controls are and where the values are kept is up to you. Panes are built again each time the panel opens, so a widget that has been disposed simply stops answering and its pane disappears. AddPane returns null when the id is empty or already in use.

Page helpers

There is no jQuery on the page. hydra.js has small helpers instead, such as SetHtml, SetText, SetValue, GetValue, Show and Hide, each taking a selector. A widget written for an older build that calls $(...) fails as soon as it runs.

SetValue fires change. Filling a form field by field therefore runs each field's onchange as you go. A handler that reads every field and writes them all back to a record then runs while the later fields are still blank, and blanks the record. Use oninput for handlers like that (SetValue does not fire input), or give each field a handler that reads only that field.

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 initialization on a page that is already running. This is where most widget bugs are.
  5. Does a loop that queues itself with requestAnimationFrame stop when this.Disposed is set?
  6. Does it subscribe to a topic before it claims the packet that raises it?
  7. Is the element id unique on the page? Two declarations with one id mount two widgets against one element.
  8. Does it survive thisvessel being null? It will be, before assignment and after a reset.
  9. 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. api/widget-sources lists every widget the server can serve and which module's copy is the one in use.

To make it appear on a console without editing that console, declare it from a component, a vessel class or a mission, or attach it to every screen that carries a given tag (see Widgets). 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.

If widgets are plainly on screen but workbench.Widgets.Instances is empty and Find finds nothing, look for a script on the screen that imports /js/hydra-module.js. That import starts a second, empty widget controller. See Client Architecture.

Last updated 19 October 2026