Everything on the admin GameScript channel so far has been request/reply. This adds the other direction: subscribe_events() opens a push stream so a bot can react to the game instead of polling it, consumed either by awaiting wait_for_event() or via an on_event callback. Both see every event; an event goes to at most one waiter, and unclaimed ones sit in a bounded buffer. Sixteen kinds, from two sources. The engine raises no GameScript event for a vehicle reaching a stop or cargo arriving, so vehicle_arrive, vehicle_depart and cargo_waiting are synthesised by the bridge sampling state every `interval` ticks and diffing against the previous sample -- which means a stop shorter than the interval is never reported, and the first sample only establishes a baseline. The rest (crashes, industries, towns, companies, subsidies) are engine events forwarded verbatim. vehicle_lost, vehicle_waiting_in_depot and vehicle_unprofitable are deliberately absent: the engine raises those only for AI companies, so a GameScript can never observe them. The server-side half lives in the AdminBridge GameScript, which is not in this repo -- docker/config is gitignored -- so it has to be updated separately for any of this to work. Also repoints the scheduled-dispatch E2E test at a dedicated vehicle (DISPATCH_VEHICLE_ID). It had been silently skipping because vehicle 7 carries a hand-built annual dispatch schedule, which left eight dispatch methods unverified end to end while check_public_calls.py reported them green off static analysis of the call sites. Co-Authored-By: Claude <[email protected]>
11 KiB
Game Events: Usage Guide
Everything else in this library is a request: you ask the server something and it answers.
Events are the other direction — the server tells you when something happens, so a bot can react
to the game instead of polling it in a loop. This guide covers OpenTTDAdminClient's
subscribe_events(), unsubscribe_events(), wait_for_event() and the on_event callback.
For the JSON that goes over the wire, see PROTOCOL.md.
Quick start
import asyncio
from openttd import OpenTTDAdminClient
from openttd.protocol import GameEventType
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="EventWatcher")
await admin.connect(admin_password="asd", secure=True)
await admin.joined.wait()
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
)
for _ in range(10):
event = await admin.wait_for_event(timeout=60.0)
print(f"vehicle {event['vehicle_id']} {event['event']} "
f"at station {event['station_id']}")
await admin.unsubscribe_events()
await admin.quit()
asyncio.run(main())
Two ways to consume events
Both see every event, so you can use either or mix them.
Pull — await admin.wait_for_event(kind=None, timeout=5.0). Returns the next matching event.
kind is a GameEventType (or plain string), an iterable of them, or None for "anything".
Events that arrive while you are not waiting are buffered, so nothing is lost between two calls,
and each event goes to at most one waiter. Raises asyncio.TimeoutError if nothing matching
arrives in time, ConnectionError if the admin connection drops mid-wait.
# The next arrival or departure, whichever comes first.
event = await admin.wait_for_event(
{GameEventType.VehicleArrive, GameEventType.VehicleDepart}, timeout=30.0)
Push — admin.on_event = handler. A plain callback (like on_chat / on_console), invoked
with each event dict as it arrives. Use it for a long-running loop that reacts to everything:
def handle(event):
if event["event"] == "cargo_waiting" and event["delta"] > 0:
print(f"{event['delta']} units of cargo {event['cargo_id']} "
f"arrived at station {event['station_id']}")
admin.on_event = handle
await admin.subscribe_events(events=[GameEventType.CargoWaiting])
await asyncio.sleep(600) # events arrive on the connection's own task
The buffer behind wait_for_event() holds the last 256 unclaimed events by default
(OpenTTDAdminClient(..., event_buffer_size=N)); past that the oldest are dropped, so a
subscription nobody drains cannot grow without bound.
Event kinds
Import them from openttd.protocol as GameEventType, or just use the string. They come in two
flavours, which behave differently in ways worth knowing.
Polled events — sampled, not instantaneous
The engine raises no event when a vehicle reaches a stop or when cargo shows up, so the
AdminBridge GameScript synthesises these by sampling game state every interval ticks and
reporting what changed since the previous sample.
| Kind | Fires when | Fields |
|---|---|---|
vehicle_arrive |
a vehicle starts loading/unloading at a station | vehicle_id, station_id, owner, vehicle_type, order_position, cargo |
vehicle_depart |
a vehicle stops loading/unloading at a station | the same, plus dwell (ticks it stayed) |
cargo_waiting |
a station's waiting amount of a cargo changes | station_id, cargo_id, waiting (new total), delta (signed change) |
cargo is a list of {"cargo_id": n, "load": units} for the cargo actually aboard (omit it with
include_cargo=False). vehicle_type is 0 rail, 1 road, 2 water, 3 air.
order_position is the index of the vehicle's current order, or -1 if it has none.
delta is negative when cargo was picked up rather than delivered.
Three consequences of sampling that you should design around:
- Short stops can be missed entirely. If a vehicle arrives and leaves between two samples,
neither event exists. Lower
intervalto narrow the window; you cannot close it. - The first sample only establishes a baseline. A vehicle already sitting at a station when
you subscribe did not just arrive and gets no
vehicle_arrive. Its eventualdwellis counted from that first sample, not from its real arrival. deltais measured against the previous sample, not the previous event. With amin_cargo_deltafilter, suppressed changes still move the baseline, so the next reporteddeltacovers everything since the last sample.
Two more edge cases in how "at a stop" is defined: it means loading or unloading at a station, so a vehicle passing through a station non-stop never registers, and a vehicle that is stopped by hand or breaks down at a platform reads as having left (and as arriving again when it resumes).
Forwarded engine events — exact, as they happen
These the engine does raise, so they are reported the moment they occur, with no sampling
involved and no interval dependence.
| Kind | Fields |
|---|---|
vehicle_crashed |
vehicle_id, tile, reason, victims, owner |
station_first_vehicle |
station_id, vehicle_id |
industry_open, industry_close |
industry_id |
town_founded |
town_id |
company_new, company_in_trouble, company_bankrupt |
company_id |
subsidy_offer, subsidy_offer_expired, subsidy_awarded, subsidy_expired |
subsidy_id |
Every event of either flavour also carries event (its kind) and tick (the game tick it was
observed at).
There is deliberately no vehicle_lost, vehicle_waiting_in_depot or vehicle_unprofitable:
the engine raises those only for AI companies, never for a GameScript, so no bridge could
forward them. Watch vehicle_arrive/vehicle_depart or poll get_timetable() instead.
events_dropped
Not something you subscribe to — the bridge emits it when a single poll produced more events than
its per-poll cap (200) and discarded count of them. Treat it as "your view has a hole in it":
if you see it, your subscription is too broad or your interval too coarse for what the map is
doing. Re-read state with get_station() / get_timetable() rather than trusting your
event-derived view.
subscribe_events(...)
Awaitable; returns the bridge's confirmation {"events": [accepted kinds], "interval": N}. Every
argument narrows what you get, and all are optional.
| Parameter | Type | Meaning |
|---|---|---|
events |
iterable | Which kinds to receive. Default: all of them. |
interval |
int |
Ticks between state samples for the polled kinds. Default 10. This is their resolution, not a delay. |
company_id |
int |
Only report vehicles and stations owned by this company. |
vehicles |
iterable | Only sample these vehicle ids instead of every vehicle. |
stations |
iterable | Only sample these station ids instead of every station. |
cargo |
iterable | Only inspect these cargo types (for cargo_waiting, and for the load on vehicle events). |
min_cargo_delta |
int |
Suppress cargo_waiting events smaller than this many units. Default 1 (report everything). |
include_cargo |
bool |
False leaves the per-cargo load off vehicle events. Default True. |
timeout |
float |
Seconds to wait for the confirmation. Default 5.0. |
Subscribing replaces any previous subscription and resets the baseline. unsubscribe_events()
stops the stream and drops the bridge's sampling state.
Errors: ValueError when the GameScript rejects the request (unknown_event,
invalid_interval, invalid_min_cargo_delta, invalid_cargo), asyncio.TimeoutError when it
does not answer (GameScripts don't run while the game is paused, so a paused server always
times out), ConnectionError if the connection drops. The Gamescript update-frequency
subscription the stream needs is set up automatically.
Cost: subscribe narrowly
The bridge samples every watched vehicle and every watched station × cargo pair on each interval, inside a GameScript's limited per-tick opcode budget. The defaults — every kind, every vehicle, every station, every cargo, every 10 ticks — are fine on a small map and wasteful on a large busy one, where the poll can take longer than the interval it is trying to keep.
Narrow it to what you actually need:
# Watch two specific vehicles closely.
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
vehicles=[7, 9], interval=2)
# Watch coal piling up at one station, ignoring changes under 10 units.
await admin.subscribe_events(
events=[GameEventType.CargoWaiting],
stations=[3], cargo=[0], min_cargo_delta=10, interval=30)
# Only one company's traffic, without the per-vehicle cargo detail.
await admin.subscribe_events(company_id=0, include_cargo=False)
Putting it together
import asyncio
from openttd import OpenTTDAdminClient
from openttd.protocol import GameEventType
async def watch_route(admin, vehicle_id):
"""Report how long a vehicle spends at each stop on its route."""
await admin.subscribe_events(
events=[GameEventType.VehicleArrive, GameEventType.VehicleDepart],
vehicles=[vehicle_id], interval=2)
try:
while True:
event = await admin.wait_for_event(timeout=300.0)
if event["event"] == "vehicle_arrive":
print(f"arrived at station {event['station_id']} (order {event['order_position']})")
else:
loaded = sum(c["load"] for c in event["cargo"])
print(f"left station {event['station_id']} after {event['dwell']} ticks "
f"carrying {loaded} units")
except asyncio.TimeoutError:
print("vehicle went quiet")
finally:
await admin.unsubscribe_events()
async def main():
admin = OpenTTDAdminClient("127.0.0.1", admin_name="RouteWatcher")
await admin.connect(admin_password="asd", secure=True)
await admin.joined.wait()
await watch_route(admin, vehicle_id=7)
await admin.quit()
asyncio.run(main())
Requirements
The server must run the bundled AdminBridge GameScript (version 3 or newer). Events need no
server patch — unlike get_timetable() and get_dispatch(), every getter involved is part of the
stock GameScript API. Since GameScripts do not tick while the game is paused, no events are
produced on a paused server.
See also
- PROTOCOL.md — Game Events for the request/reply JSON.
- TIMETABLES.md for reading and editing what the events tell you changed.
- ARCHITECTURE.md for how the admin client fits together.