# 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](PROTOCOL.md#game-events). ## Quick start ```python 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. ```python # 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: ```python 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 `interval` to 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 eventual `dwell` is counted from that first sample, not from its real arrival. - **`delta` is measured against the previous sample, not the previous event.** With a `min_cargo_delta` filter, suppressed changes still move the baseline, so the next reported `delta` covers 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: ```python # 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 ```python 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) from [`gamescript/AdminBridge/`](../gamescript/AdminBridge/README.md) — `get_bridge_version()` checks that it is new enough, and it is worth calling once at startup. 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](PROTOCOL.md#game-events) for the request/reply JSON. - [TIMETABLES.md](TIMETABLES.md) for reading and editing what the events tell you changed. - [ARCHITECTURE.md](ARCHITECTURE.md) for how the admin client fits together.