Agent Bridge
The Agent Bridge lets one program outside the game play one player. The program reads what the player can see and sends the player's commands over a named pipe. CONTROL hosts the bridge; no Lua module is needed. Every command goes through the same checks and command queue as a module's commands, so the program can do what a module can do and nothing more.
CONTROL 1.1.0 had a different bridge, opened by a Lua module with AgentBridge.Start() on
\\.\pipe\AoE2ControlAgentV1. CONTROL 1.1.1 replaces it: the Lua functions and the old pipe are
gone, and programs written for 1.1.0 must use the protocol on this page.
Turning it on
- In the CONTROL menu, open Modules and turn on Agent Bridge.
- Below it, turn on the players the bridge may control.
- Connect your program to
\\.\pipe\AoE2ControlAgentV3.P1(for player 1), open a session and start observing.
The settings in settings.ini:
Setting (group [modules]) |
Default | Effect |
|---|---|---|
Agent Bridge |
false |
Turns the bridge on. The menu shows it under Modules and, while it is on, one checkbox per player below it. |
Agent Bridge Player 1 to Agent Bridge Player 8 |
false |
Opens the pipe for that player. |
While the bridge is on for any player, Lua modules read the game-state copy, as with Multithreading. The bridge does not depend on Modules > Enabled. A player can have a Lua module and a bridge at the same time; both send commands for the player. With Suppress Native AI on, a bot player with the bridge turned on has its built-in AI turned off, as with a module.
The bridge is refused in multiplayer matches, also with cheats enabled. In a replay, observing works and every command is rejected.
Connecting
- One named pipe per player that has the bridge turned on:
\\.\pipe\AoE2ControlAgentV3.P<n>,<n>from 1 to 8. The pipe exists while the setting is on and CONTROL is loaded. - Only programs of the same Windows user on the same PC can connect.
- Each connection carries one request and one response: UTF-8 JSON messages. A request is at most 1 MiB, a response at most 16 MiB. Write the request, read the response, close the handle.
- The pipe always has a listening instance. Requests for one player are handled one at a time; a client that connects while another request runs waits for its turn.
- Connecting, reading and writing each time out after 5 seconds.
Requests and responses
Request:
{ "contract_version": 3, "operation": "observe", "session": "a1b2...", "payload": {} }
session is required for every operation except status and open, and is omitted there.
Unknown keys are rejected.
Response: { "ok": true, "data": { ... } } or
{ "ok": false, "error": { "code": "...", "message": "..." } }.
Sessions
One program per player. open creates a session and returns its id. Another open for the same
player fails with player_in_use while the session is alive. A session ends with close, when
the bridge setting for the player is turned off, when CONTROL unloads, or 30 seconds after its
last request. A new match does not end a session; observations then report the new world.
Operations
status
No session. Payload {}. Returns:
| Field | Content |
|---|---|
player_id |
The pipe's player. |
state |
waiting_for_match, ready, multiplayer_refused. |
session_open |
true while a session is alive. |
information_policy |
player_view, or omniscient while Modules See Everything is on. |
counters |
actions_accepted, actions_rejected, actions_executed, observations, counted since the bridge for this player was turned on. |
open
Payload { "client_name": "my-agent" }, 1 to 64 printable ASCII characters. Returns session,
player_id, information_policy, limits and the catalog (see Catalog).
observe
Payload {} or { "entity_fields": ["id", "type_id", ...] } to limit the per-object fields.
Returns the newest observation (see Observation). Fails with
observation_unavailable before a match runs.
map
Payload {}. Returns the map as base64 byte arrays, row by row (index = y * width + x):
| Field | Content |
|---|---|
width, height |
Tiles. |
world, sequence |
The observation the map was read from. |
encoding |
"base64-u8". |
terrain |
Terrain id (Terrain enum); 255 for a tile the player has not explored. |
elevation |
Elevation; 255 for an unexplored tile. |
visibility |
0 unexplored, 1 explored, 2 visible. With omniscient, every tile reads 2. |
passable |
Bit 0: land units can stand there (nothing blocks it). Bit 1: the terrain lets land units move, whatever stands on it. 0 for an unexplored tile and for water. |
act
Payload:
{
"world": 3,
"actions": [
{ "command": "gameplay.train.v1", "args": { "object_type": 83, "amount": 2 } },
{ "command": "gameplay.target.v1", "args": { "actors": [412, 415], "target": 988 } }
]
}
worldis theworldof the observation the program acted on. If the match changed since, the whole request fails withworld_changed.- 1 to 32 actions. They are checked in order against the newest observation of the player and queued; the game runs them on its next update.
- At most 64 actions of a player may wait in the queue (for example while the game is paused).
More fail with
action_budget.
The response waits until the queued actions ran, at most 5 seconds. data.results has one entry
per action, in request order:
| Field | Content |
|---|---|
action_id |
A number that identifies the action in later observations. null for an action that was rejected before it was queued. |
status |
executed, rejected or queued (still waiting when the response was sent, for example while the game is paused). |
stage |
For rejected: arguments (the arguments do not match the catalog), validation (checked against the observation) or execution (checked by the game when it ran). |
reason |
For rejected: a reason code (see Reasons). |
executed means the game accepted the command. Whether the units do what was asked shows in
later observations.
query
Read-only questions about the newest observation.
{ "kind": "path", "from": {"x": 10.5, "y": 20.5}, "to": {"x": 40.0, "y": 22.0}, "radius": 0.5 }returnspoints, a list of{x, y}, empty when there is no path.radiusis optional.{ "kind": "placement", "object_type": 70, "positions": [{"x": 30, "y": 41}, ...] }checks 1 to 64 positions with the game's own placement check for the player. Runs on the game's next update. Returnsresults, one per position:{ "result": <PlacementResult id>, "name": "CAN_PLACE", "blocking_object": <id or null> }.
close
Payload {}. Ends the session.
Catalog
open returns catalog:
commands: one entry per command:id,descriptionandargs, a list of{ "name", "type", "required", ... }. Types:object_ids(withminandmaxcount),object_id,position({x, y}in tiles),integer(withminandmax),boolean,enum(withvalues, a list of{ "name", "value" }) andstring(withmax_length).entity_fields: the per-object fieldsobservecan return.facts: the fact ids inplayer.facts.
Commands in version 3:
| Command | Arguments | What it does |
|---|---|---|
gameplay.train.v1 |
object_type, amount (1–100, default 1), producers (optional) |
Trains or builds units of a type. Without producers, the game uses the player's most common building that can make it. Counts units already queued: asking for 2 when 1 is queued queues 1 more. |
gameplay.research.v1 |
technology, producer (optional) |
Researches a technology. |
gameplay.target.v1 |
actors, target |
Right-clicks a target: gather, attack, build, repair, garrison, as the game decides. |
gameplay.build.v1 |
actors, object_type, position |
Places a building foundation and sends the builders. |
gameplay.move.v1 |
actors, position |
Moves units. |
gameplay.enable_scouting.v1 |
none | Sends the player's idle scout to explore. |
gameplay.auto_scout.v1 |
actors |
Sends units to explore. |
gameplay.patrol.v1 |
actors, position |
Patrols to a position. |
gameplay.attack_move.v1 |
actors, position |
Attack-moves to a position. |
gameplay.guard.v1 |
actors, target |
Guards an object. |
gameplay.follow.v1 |
actors, target |
Follows an object. |
gameplay.garrison.v1 |
actors, target |
Garrisons units in an object. |
gameplay.ungarrison.v1 |
actors (buildings), target (a unit of the player) |
Ungarrisons the unit from the buildings. |
gameplay.seek_shelter.v1 |
actors |
Sends units to seek shelter. |
gameplay.combat_stance.v1 |
actors, stance (UnitCombatStance) |
Sets the combat stance. |
gameplay.formation.v1 |
actors, formation (Formation) |
Sets the formation. |
gameplay.gather_point.v1 |
actors (buildings), position |
Sets the gather point. |
gameplay.town_bell.v1 |
target (a town center of the player), calling_in |
Rings the town bell or sounds the all-clear. |
gameplay.back_to_work.v1 |
target (a building of the player) |
Sends the villagers garrisoned in it back to work. |
gameplay.all_back_to_work.v1 |
target (a building of the player) |
Sends the villagers garrisoned in all of the player's buildings back to work. |
gameplay.delete.v1 |
target (a unit of the player) |
Deletes a unit. Cannot be undone. |
gameplay.destroy_building.v1 |
target (a building of the player) |
Destroys a building. Cannot be undone. |
player.chat.v1 |
message (1–200 characters) |
Sends a chat message as the player. |
actors lists 1 to 256 different object ids. Each actor must be the player's, alive and fully
visible to the player; otherwise the action is rejected with unavailable_actor.
Not in the catalog: camera, game speed, pause, saving, starting and ending matches.
Observation
| Field | Content |
|---|---|
world, sequence, session_generation |
Generation numbers. world changes with every new match. sequence grows with every captured state. |
world_turn, game_time_ms |
Game time of the observation. |
information_policy |
player_view or omniscient. |
match |
map_width, map_height, player_count, victory_condition, replay. |
player |
The bridge's player: id, name, civilization_id, civilization_name, player_type, color, has_won, attributes (a list of numbers by ResourceType id), facts (object of fact name to value), diplomacy (list by player id). |
players |
Every player: id, name, civilization_id, player_type, color, has_won, diplomacy. Includes attributes only for the bridge's player and, with omniscient, for every player. |
object_types |
For the bridge's player, every valid object type: id, available, can_afford, can_afford_without_population, cost (list of {resource, amount}), train_site, count, count_with_queued. |
technologies |
For the bridge's player, every technology that is not NOT_AVAILABLE: id, state (ResearchState), can_afford, cost. |
entities |
The objects the player can see (see below). |
chat |
The chat messages the game holds. |
pending_actions |
Actions of this player that wait in the queue. |
recent_results |
The last 64 action results of the session, as in act, with action_id. |
Entities: every object the player can see. An object the player has explored but cannot see now
(for example a gold mine in the fog) has access: "position" and only the fields id, type_id,
class_id, owner, x, y, access. Other objects have access: "full" and all requested
fields. id and access are always included. A garrisoned unit has the position of the object
it is in and garrison_object_id set; of its other fields only type_id, class_id,
object_type, owner, status, alive and commandable hold values. The fields:
id, type_id, type_name, name, class_id, object_type, owner (null for none),
x, y, z, tile_x, tile_y, hitpoints, max_hitpoints, alive, idle, moving,
scouting, status (2 finished, 0 foundation), commandable (the player can command it),
target_object_id, target_position ({x, y}), action_target_position ({x, y} or null),
garrison_object_id, radius_x, radius_y, action, train_count, researching,
progress_type, progress_value, access.
Positions are in tiles, as in Lua. Limits lists the bridge's limits in one table.
Reasons
Argument errors (stage: "arguments"): unknown_command, invalid_arguments.
Validation and execution (stage: "validation" or "execution"): unavailable_actor,
missing_snapshot, inactive_session, replay, generation_mismatch, missing_sources,
unavailable_sources, missing_target, unavailable_target, foreign_target,
invalid_payload, queue_unavailable, module_unavailable, unavailable_object_type,
unavailable_technology, population_capacity, unaffordable, already_satisfied,
command_unavailable. They mean the same as the Lua command rejections in the module log.
Error codes
| Code | Meaning |
|---|---|
invalid_request |
The request or its payload does not match this page. |
unsupported_contract_version |
contract_version is not 3. |
unknown_operation |
No such operation. |
invalid_session, session_expired |
The session id is unknown, or the session ended. |
player_in_use |
Another program has a session for this player. |
multiplayer_refused |
The match is a multiplayer match. |
observation_unavailable |
No match runs, or the player does not exist in it. |
world_changed |
act named another match than the current one. |
action_budget |
Too many actions wait in the queue. |
query_unavailable |
The query could not run, for example because the game did not update in time. |
request_too_large, response_too_large |
A message exceeded its limit. |
Example client
A minimal Python client for Windows, with no dependencies:
import ctypes, ctypes.wintypes as wt, json
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateFileW.restype = wt.HANDLE
def call(player, request):
pipe = kernel32.CreateFileW(rf"\\.\pipe\AoE2ControlAgentV3.P{player}", 0xC0000000, 0, None, 3, 0, None)
mode = wt.DWORD(2) # message mode
kernel32.SetNamedPipeHandleState(pipe, ctypes.byref(mode), None, None)
body = json.dumps(request).encode()
kernel32.WriteFile(pipe, body, len(body), ctypes.byref(wt.DWORD()), None)
buffer, chunks = ctypes.create_string_buffer(1 << 20), []
while True:
read = wt.DWORD()
ok = kernel32.ReadFile(pipe, buffer, len(buffer), ctypes.byref(read), None)
chunks.append(buffer.raw[:read.value])
if ok or ctypes.get_last_error() != 234: # 234: more data
break
kernel32.CloseHandle(pipe)
return json.loads(b"".join(chunks))
session = call(1, {"contract_version": 3, "operation": "open", "payload": {"client_name": "example"}})["data"]["session"]
observation = call(1, {"contract_version": 3, "operation": "observe", "session": session, "payload": {}})["data"]
print(call(1, {"contract_version": 3, "operation": "act", "session": session, "payload": {
"world": observation["world"],
"actions": [{"command": "gameplay.train.v1", "args": {"object_type": 83}}]}}))
A real client should retry opening the pipe on errors 2 and 231 and check ok in every response.