Starship Horizons
Log In
☰
Knowledge Base›Client Architecture

Client Architecture

Horizons has no client application. Every screen in the game — a bridge console, the Main Viewer, the Game Master station, the editor — is a web page served by the game server and drawn in a browser using WebGL. The server runs the simulation and holds all of the state; the browser draws and sends input.

The two connections

Every console makes two, and both must work:

  • HTTP on port 1864 delivers the page and its assets. Port 1866 is the TLS equivalent.
  • A WebSocket on the same port carries the live game. The page opens it to the address and port it was itself served from, so it is 1864 for a page from 1864 and 1866 for a page from 1866.

Older guides give the WebSocket a port of its own, 1865, or 1867 for TLS. The client no longer uses either and the server no longer listens on them. The port numbers on the <websockets> line of config.xml are left over from that. Leave them in place, because the rest of the line still applies and the server expects to find them.

A page that loads but never comes alive has the first and not the second. With one port for both, the usual cause is something between the browser and the server, such as a proxy or a tunnel, that passes ordinary requests but not the WebSocket upgrade. That is worth knowing before you debug anything else — see Hosting Over The Internet.

Screens

A screen is a registered page. Screens are declared in XML, one registration per station:

<screen name="Flight" icon="roles/badge-flight.webp" base="console.htm"
        url="flight" file="flight.htm" role="Flight"
        mission-summary="true" packets="" tags="role-primary,flight" />
AttributeMeans
nameThe display title. This is what the GM console lists.
urlThe address slug. Not the same as the name, which catches people out.
baseWhich base page wraps it.
fileThe screen content itself.
roleWhich vessel role this station binds as. It also picks the console's input role: the set of actions its keys and controllers can be bound to.
iconThe badge shown beside it on the station selection page.
packetsServer packets this screen wants from the outset.
visibleWhether it appears on the station selection page. Invisible screens are still reachable at their address.
assign-vesselWhether opening it binds to a vessel. False for admin stations.
mission-summaryWhether this station shows the mission's epilogue when it ends. Even then it appears only when the mission has a Standard epilogue that names a widget.
hostMakes the screen a Host lobby: one player, the Host, sets the session up (mission, options, who flies what, launch) and everyone else only picks their own vessel.
tagsComma-separated labels. A module can attach a widget to every screen that carries a given tag. See Widgets.

Registrations sit in <screens id="..."> groups. The id is the heading the station selection page lists them under, and groups are listed in order of their ids, which is why the stock ids start with spaces.

Screen registrations are contributed by modules and merged after the base game loads, so a module can add stations or replace existing ones. See Modules.

Base pages

A console page is a base page with the screen's content injected into it. There are four bases, and the choice matters:

  • console.htm — a player console. Chrome, alert bar, role indicators, and the waiting / offline / spawning states.
  • console-admin.htm — an admin station.
  • console-child.htm — a page hosted inside another, used by the editor for its document tabs.
  • dialog.htm — a small standalone panel.

Hydra Studio adds a fifth, console-hydra.htm, for its own shell.

The base page declares an import map, so that widgets can import three.js by name, and loads hydra.js (the whole client runtime, as a classic script exposing globals) and hydra-module.js (the ES module entry point, which creates the widget controller). The screen's own content is placed where the base says [Screen.Content].

Do not import hydra-module.js from a screen's own script. Nothing in it is needed there: the controller is already on the page as workbench.Widgets. The base page loads the module with the version suffix on its address, so an import without that suffix runs it a second time, and the second copy puts a new, empty controller in workbench.Widgets. The page's widgets keep running, but nothing can find them any more, and anything mounted afterwards lands in the other controller.

The .msp extension

Pages are requested with a .msp suffix — flight.msp, gamemaster.msp. The server strips it, looks the screen up by its url slug, renders the base page around the screen file and substitutes tokens. A request without .msp gets a static file, not a rendered screen.

Server-side tokens

Bracketed tokens in a page are replaced by the server before it is sent:

TokenValue
[Screen.Title], [Screen.Name]This screen's identity: its display name in capitals, and its address slug
[Screen.Content]The screen file, injected into the base
[Screen.Roles], [Screen.MissionSummary]Screen configuration, as metadata
[Server.LocalVersion], [Server.Version]Build version
[Server.IP], [Server.Port]Where this server is
[Game.AvailableScreens], [Game.ActiveScreens]Station lists
[Vessel.Alert], [Vessel.Name], [Vessel.Designation]The bound vessel

Only rendered .msp pages get this treatment. A template or a widget layout that a script fetches afterwards arrives exactly as it is on disk, so a token written into one shows up on screen as literal text. [Screen.Url] is also left alone, even in a page, because some screens carry row templates whose scripts fill it in.

Two of these do more than they look:

[Server.LocalVersion] is the cache‑buster. Every script and stylesheet is requested as panel.css?[Server.LocalVersion]. It changes when the build version changes — which means editing a stylesheet does not invalidate it. Already‑running clients keep the old sheet until they are reloaded. Freshly requested .msp pages are always current.

[Vessel.Alert] selects a stylesheet. The page links alert[Vessel.Alert].css, so the alert condition physically swaps a stylesheet rather than toggling classes. That is how a whole console restyles itself at Red.

Console states

A player console is always in one of several states, each a panel in the base page that is shown or hidden: offline (no connection), waiting (connected, no vessel assigned), spawning, and online. The waiting state is the one players report as "my console does nothing" — it means no vessel, which is a Game Master matter, not a client one.

A mission can be set to start only when its players are ready. Each seated console then reports once it has finished loading: no model still downloading, and every 3D view it can see has its map and has finished preparing what it drew. The server holds the start until every console has reported, or until its own timeout runs out. A console that never settles reports anyway after 20 seconds, so one broken file cannot hold everyone, and it writes to the browser console every two seconds what it is still waiting on.

What the client holds

hydra.js maintains a set of globals that page code and widgets read directly:

  • game — the session: players, missions, variables, events, and the broadcast bus.
  • thisvessel — the vessel this console is bound to. Almost everything a widget renders comes from here. On a Game Master console it is whatever that console has selected, and each GM console has its own selection.
  • workbench — the page itself, including workbench.Widgets, the widget controller.
  • socket — the live connection.

These are globals on purpose: widgets are loaded dynamically and independently, and this is the shared surface they agree on. See Widgets and Packets And Topics.

Layout and 3D

Consoles are laid out with flexbox and a set of panel classes (panel-area, panel-container, panel-header, panel-full) rather than absolute positioning, so a console adapts to whatever screen it is on.

The 3D views — viewport, main viewer, radar — are three.js scenes drawn into canvases, loaded through an import map so widgets can import "three" directly. Several of them can be on one page at once: a role console typically has a viewport, a 3D radar, and two smaller relative radars, each with its own render loop.

Performance, briefly

Because a console page can hold nearly a dozen independent animation loops, the useful measurement is milliseconds of JavaScript per frame, attributed to the loop that spent it — not frame rate. Frame rate is capped by the display's refresh and hides all available headroom, so a console can look perfectly smooth while one widget is using most of the budget. Radar widgets have historically been the surprise.

Last updated 19 October 2026