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" />
| Attribute | Means |
name | The display title. This is what the GM console lists. |
url | The address slug. Not the same as the name, which catches people out. |
base | Which base page wraps it. |
file | The screen content itself. |
role | Which 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. |
icon | The badge shown beside it on the station selection page. |
packets | Server packets this screen wants from the outset. |
visible | Whether it appears on the station selection page. Invisible screens are still reachable at their address. |
assign-vessel | Whether opening it binds to a vessel. False for admin stations. |
mission-summary | Whether 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. |
host | Makes 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. |
tags | Comma-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:
| Token | Value |
[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.
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.