Skip to content
You're reading the docs for Railroader 0.6 (the MoW Update), currently available on the Experimental branch.

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.

  1. Open Preferences → Features → Experimental and enable HTTP Server. The toggle takes effect immediately — no restart needed.
  2. 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.

PathReturns
/Landing page with links to the demos
/api/infoServer name, game version, timestamp, local player id, and the game session state (JSON)
/propertiesProperty browser demo
/throttleExternal throttle demo with live gauges

Everything else is served as a static file from the game’s StreamingAssets/HTTP folder.

Connect to ws://localhost:8630/ws. That single endpoint carries everything; messages are routed by domain.

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, or system.
  • 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
}

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.

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:

StateMeaning
menuNo game is loaded. The game domains will refuse.
loadingA save is loading. Wait; it will become active shortly.
activeA game is loaded. Everything answers.
unloadingThe 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 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.

Reads and writes properties — the same key/value state that Relay scripts see. Property names and their value types are documented on that page.

{ "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
}
}
{ "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.

{
"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 }
}
{ "id": 4, "domain": "properties", "type": "unsubscribe", "data": { "id": "C-1234", "keys": ["throttle"] } }

Omit keys to drop every subscription on that object.

{
"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.

Tracks the car the player currently has selected.

TypeEffect
getReturns the selected car right now
subscribeReturns the selected car, then pushes an event whenever it changes
unsubscribeStops those pushes
followPoints 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.

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:

FieldPresent onUnit
id, speedevery carmph, unsigned
brakePipe, brakeCylinder, mainReservoir, equalizingReservoirlocomotivespsi
ampsdiesel locomotivesamps
boilerPressuresteam locomotivespsi

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.

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.

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.