WebSocket API
Railroader can run a small HTTP server inside the game that lets programs on the same machine read and change game state in real time. It’s how the built-in demo pages work — a browser tab showing live locomotive gauges, or an external throttle — and it’s available to anything you write yourself: a Node script, a Python tool, a Stream Deck plugin, a hardware control panel.
Enabling the server
Section titled “Enabling the server”- Open Preferences → Features → Experimental and enable HTTP Server. The toggle takes effect immediately — no restart needed.
- Browse to
http://localhost:8630/for the landing page and links to the demos.
The default port is 8630. It’s stored in the http.server.port preference, which the Preferences window doesn’t currently expose.
The server starts with the game and stays up at the main menu, so you can connect before loading a save. Anything that reads the world, though, needs a game session — see Waiting for a game session.
If you’d rather not leave the preference on, launch with -httpserver instead. That runs the server for the session without changing the stored setting. On Steam: Properties → General → Launch Options.
HTTP endpoints
Section titled “HTTP endpoints”| Path | Returns |
|---|---|
/ | Landing page with links to the demos |
/api/info | Server name, game version, timestamp, local player id, and the game session state (JSON) |
/properties | Property browser demo |
/throttle | External throttle demo with live gauges |
Everything else is served as a static file from the game’s StreamingAssets/HTTP folder.
The WebSocket
Section titled “The WebSocket”Connect to ws://localhost:8630/ws. That single endpoint carries everything; messages are routed by domain.
Message envelope
Section titled “Message envelope”Every message is JSON with the same four fields:
{ "id": 1, "domain": "properties", "type": "get", "data": { }}id— a number you choose, used to match a response to its request. Increment it per request. Required on every message you send.domain— which part of the game the message is for:properties,player,telemetry, orsystem.type— what you want to do, e.g.get,subscribe.data— the arguments, which vary by domain and type.
The server replies with the same id, domain, and type you sent, plus a data payload:
{ "id": 1, "domain": "player", "type": "get", "data": { "selectedCar": null } }The server also pushes messages you didn’t ask for — a property changed, a telemetry sample. Those look identical except that they have no id. That’s the only reliable way to tell a response from a push, so branch on it:
if (msg.id === undefined) { // server-initiated event}Connecting
Section titled “Connecting”Right after the socket opens, the server sends:
{ "domain": "system", "type": "connected", "data": { "sessionId": "…", "timestamp": "2026-01-01T12:00:00.0000000Z", "session": { "state": "menu", "railroadName": null, "railroadMark": null, "gameMode": null } }}Each connection is independent. Subscriptions belong to the socket that created them and are dropped when it closes, so a reconnecting client has to re-subscribe.
Waiting for a game session
Section titled “Waiting for a game session”The server runs from the moment the game launches, well before any save is loaded. The properties, player, and telemetry domains all read the live world, so until a game is loaded they refuse every message:
{ "id": 4, "domain": "system", "type": "error", "error": { "message": "No game session is loaded (state: menu)", "code": "NoGameSession" }}The session state arrives with the connected message above, and the server pushes it again — with no id, like any other event — every time it changes:
{ "domain": "system", "type": "session", "data": { "state": "active", "railroadName": "Bushnell & Whittier", "railroadMark": "BW", "gameMode": "Company" }}state is one of:
| State | Meaning |
|---|---|
menu | No game is loaded. The game domains will refuse. |
loading | A save is loading. Wait; it will become active shortly. |
active | A game is loaded. Everything answers. |
unloading | The game is being torn down. Drop any object ids you’re holding. |
The railroad fields are only filled in while the state is active; otherwise they’re null.
A well-behaved client connects, notes the state, and subscribes once it becomes active rather than when the socket opens. When it leaves active, throw away every car id you were holding — they mean nothing in the next session, and the server has already dropped its side of your subscriptions.
You can also ask for the state at any time. This is the one domain that always answers:
{ "id": 1, "domain": "system", "type": "session" }Errors
Section titled “Errors”Errors always come back in the system domain, carrying the id of the request that caused them when there is one:
{ "id": 4, "domain": "system", "type": "error", "error": { "message": "Object not found: car_invalid", "code": "ObjectNotFound" }}Common codes: InvalidMessage, UnknownDomain, UnknownType, NoGameSession, ObjectNotFound, Unauthorized, ServiceUnavailable.
ServiceUnavailable means the game is behind on work, not that anything is broken — messages are handed to the main thread, and that queue has a ceiling. Back off and retry.
The properties domain
Section titled “The properties domain”Reads and writes properties — the same key/value state that Relay scripts see. Property names and their value types are documented on that page.
list — find objects
Section titled “list — find objects”{ "id": 1, "domain": "properties", "type": "list", "data": { "filter": "SD40" } }filter is optional; when present it’s a case-insensitive substring match against a car’s id, display name, or kind. Cars come back with a displayName and their definition kind as type; everything else is reported with type of "Object".
{ "id": 1, "domain": "properties", "type": "list", "data": { "objects": [ { "id": "C-1234", "type": "DieselLocomotive", "displayName": "UP 1234" } ], "count": 1 }}get — read every property on one object
Section titled “get — read every property on one object”{ "id": 2, "domain": "properties", "type": "get", "data": { "id": "C-1234" } }Responds with { "id": "C-1234", "values": { … } }, a map of every key on the object to its current value.
subscribe — watch specific keys
Section titled “subscribe — watch specific keys”{ "id": 3, "domain": "properties", "type": "subscribe", "data": { "id": "C-1234", "keys": ["throttle", "reverser"] }}keys is required. The response confirms the keys now being watched. After that, each change arrives as a push:
{ "domain": "properties", "type": "value", "data": { "id": "C-1234", "key": "throttle", "value": 0.75 }}unsubscribe — stop watching
Section titled “unsubscribe — stop watching”{ "id": 4, "domain": "properties", "type": "unsubscribe", "data": { "id": "C-1234", "keys": ["throttle"] } }Omit keys to drop every subscription on that object.
set — change a value
Section titled “set — change a value”{ "id": 5, "domain": "properties", "type": "set", "data": { "id": "C-1234", "key": "throttle", "value": 0.5 }}Writes go through the game’s normal permission checks — the same ones that govern whether you could move that control by hand. A write you aren’t entitled to make comes back as Unauthorized.
The player domain
Section titled “The player domain”Tracks the car the player currently has selected.
| Type | Effect |
|---|---|
get | Returns the selected car right now |
subscribe | Returns the selected car, then pushes an event whenever it changes |
unsubscribe | Stops those pushes |
follow | Points the overhead camera at a car and tracks it |
A car is reported as an object with id, displayName, and type fields, or null when nothing is selected.
→ {"id": 1, "domain": "player", "type": "subscribe"}← {"id": 1, "domain": "player", "type": "subscribe", "data": {"selectedCar": {"id": "C-1234", …}}}← {"domain": "player", "type": "selectedCar", "data": {"selectedCar": null}}follow takes an optional id in data and falls back to the selected car when it’s omitted.
The telemetry domain
Section titled “The telemetry domain”Pushes gauge readings for a car on a timer. Use this rather than polling properties.
{ "id": 6, "domain": "telemetry", "type": "subscribe", "data": { "id": "C-1234", "frequency": 10 } }id is required. frequency is in hertz, clamped to 1–10, and defaults to 5. Subscribe again for the same car to change its rate. unsubscribe takes the same id.
Samples arrive as data events:
{ "domain": "telemetry", "type": "data", "data": { "id": "C-1234", "speed": 15.2, "brakePipe": 90.0, "brakeCylinder": 0.0, "mainReservoir": 140.0, "equalizingReservoir": 90.0, "amps": 450.0 }}Which fields are present depends on the car:
| Field | Present on | Unit |
|---|---|---|
id, speed | every car | mph, unsigned |
brakePipe, brakeCylinder, mainReservoir, equalizingReservoir | locomotives | psi |
amps | diesel locomotives | amps |
boilerPressure | steam locomotives | psi |
If a subscribed car goes away — deleted, or the save unloaded — its subscription is quietly dropped and the samples simply stop. Watch the session state if you need to know which happened.
The saves domain
Section titled “The saves domain”Loads a save, from the main menu or over a running singleplayer game. Unlike the other domains, it answers at the menu.
list returns each railroad’s folder name and its saves, plus current, the save the running game came from. load takes those names and discards any game in progress without saving:
{ "id": 1, "domain": "saves", "type": "load", "data": { "railroad": "dillsboro-railway", "save": "before-the-grade" } }Omit save to load the railroad’s main save. The response only means the load has started; watch the session state for active. Errors: SaveNotFound, Busy (already loading or unloading), and NotSingleplayer.
A minimal client
Section titled “A minimal client”const ws = new WebSocket('ws://localhost:8630/ws');let nextId = 1;
const send = (domain, type, data = {}) => ws.send(JSON.stringify({ id: nextId++, domain, type, data }));
ws.onmessage = (event) => { const msg = JSON.parse(event.data);
if (msg.domain === 'system' && msg.type === 'error') { console.error(msg.error.code, msg.error.message); return; }
// Subscribe once the game is loaded, not when the socket opens: that can happen // at the main menu. The state arrives with 'connected' and again on every change. if (msg.domain === 'system' && (msg.type === 'connected' || msg.type === 'session')) { const session = msg.type === 'connected' ? msg.data.session : msg.data; if (session.state === 'active') send('player', 'subscribe'); return; }
// A player-domain message with no id is a change notification. if (msg.domain === 'player' && msg.id === undefined) { const car = msg.data.selectedCar; if (car) send('telemetry', 'subscribe', { id: car.id, frequency: 10 }); }
if (msg.domain === 'telemetry' && msg.type === 'data') { console.log(msg.data.id, msg.data.speed.toFixed(1), 'mph'); }};The game ships a fuller client as railroader-ws.js, served at http://localhost:8630/railroader-ws.js — it wraps the envelope, correlates responses, and exposes each domain as an object. The demo pages are built on it, and their source is worth reading if you’re writing a browser tool. View any of them with your browser’s developer tools to see the traffic.
© 2026 Giraffe Lab LLC. All rights reserved.