Docs Core concepts

Mental Model

Snapshots, commands, receipts, events, ticks and batches. Understand these six concepts and you'll understand why the API looks the way it does, and how to write fast code.

Snapshots: reads with zero wait

Every 50 ms, the runtime samples the entire world on the game thread and writes it into shared memory. What g.snapshot() gives you is a complete, self-consistent world:

  • 16 player slots: gold, lumber, food, food cap, total resources harvested, race;
  • Up to 1024 units: type, owner, coordinates, HP / mana (with maximums), current order and order target, what it is actually attacking (task target), hero level / XP / skill points, and each player’s visibility of it;
  • Up to 256 unit details: 12 abilities (level, cooldown remaining), 8 buffs, 6 inventory slots;
  • Ground items, trees (refreshed every 2 seconds), production table (training / research / construction / upgrade progress), game clock, in-game time of day.

Reading one takes about 0.4 ms (Python parsing), without waiting on the game thread. So: read as much as you like. APIs like g.units(), g.my_army(), g.cooldown() and g.inventory() all pull from the same snapshot, so calling them many times in one tick costs little.

The publish period is adjustable: g.set_publish_period(ms), 16 ~ 1000 ms. One sample takes about 0.5 ~ 0.9 ms on the game thread, so even 33 ms is fine. There’s a single value shared machine-wide; the last write wins.

Commands: writes, about one frame

g.move / attack / gather / build / train / cast … are handed to the game thread for execution. The runtime executes client-submitted commands in batches inside the game thread’s event dispatch, so a command waits about one frame (about 0.1 ms when it lands in an event cluster; otherwise it waits for the next dispatch).

  • Commands accept a single unit or a list; units in a list are all ordered in the same frame;
  • Adding queue='after' is a Shift-queue: do this after finishing the current task;
  • You can pass the unit objects you got from the snapshot; the SDK verifies identity by handle pair (addresses get reused by new units, handles don’t).

Receipts: every command has one

r = g.build(worker, "hbar", x, y)
if r:                      # the engine accepted it
    ...
else:
    r.reason               # 'rejected(金不够)'  (= not enough gold)
    r.verdict              # 8
r.exec_us                  # microseconds this command took on the game thread

Receipts are read back in the same frame: the unit’s order before and after the command, the engine function’s return value, and the feasibility check’s reason code. A receipt answers “did the engine accept this command, and if not, why”, but it does not answer “did it eventually succeed” — for that, look at snapshots and events.

For all status codes and reason codes, see Receipts and reason codes.

Events: what happened

on_event(g, ev) runs before each on_tick and hands you, one by one, every event since the last tick:

EventMeaning
unit.appeared / unit.died / unit.removedA unit appeared, died, or disappeared (entering a gold mine, being converted, or a corpse decaying also count as disappearing — not the same as dying)
unit.damaged / order.changed / owner.changedLost HP, changed order, changed owner
hero.levelupA hero leveled up
item.appeared / item.removedA ground item appeared, was picked up, or was used
damageEngine-level: every single hit. Source unit, attack type, damage type, actual HP lost, pre-armor damage
killedEngine-level: this hit killed it, with the killer
production.doneTraining / research / construction / upgrade finished, with the four-character code and game seconds taken. Also emitted for opponents
spell.castA unit cast a spell: the spell’s four-character code, level, cooldown in seconds, cast point
messageA line appeared in an on-screen message frame: game hints (“You need more farms”), chat (.chat has the sender and text), system messages
selection.changed / player.leftThe local player’s selection changed / a player left or was removed after being defeated
game.started / game.endedA new game started / left the game

Input events such as canvas button clicks, hotkeys and ground clicks are covered in UI & input.

The event stream is global: opponents’ production completions and creep deaths are all in it. Filter by ev.owner or unit handle.

Ticks: the Bot’s rhythm

By default, on_tick is called 5 times per second (wall clock). A tick’s cost is basically just your own computation: snapshots have zero wait, and commands take about one frame. If a tick runs past its period, the next one is pushed back automatically — they never pile up.

  • Don’t wait by wall clock at 2× game speed. To wait 3 game seconds, watch for g.clock() to advance by 3 — don’t sleep(1.5).
  • Don’t sleep inside on_tick. If you need to “do it in a bit”, record the current game time and check again next tick.

Batches: dozens of commands, one wait

When a tick issues many commands, wrap them in with g.batch()::

with g.batch():
    g.attack(melee, target_a)
    g.attack(ranged, target_b)
    g.move(wounded, home.x, home.y)
    g.cast(hero, "thunderclap")
# the whole batch is submitted when the block ends: executed in the same frame, waiting on the game thread only once
  • Commands inside the block return Pending, which becomes a receipt when the block ends; reading it before then raises an error;
  • If an exception is raised inside the block, the whole batch is discarded (half a set of commands is more dangerous than none);
  • Measured with 8 moves: 68 ~ 99 ms one at a time, 6.5 ~ 10 ms as a batch.

The same idea applies to queries: g.can_do_many([(u, code), ...]) and g.tech_many([...]) ask about many things at once.

Reading what you just wrote

Within the same tick, the snapshot doesn’t yet reflect the commands you just issued (it catches up on the next publish). Two pieces of logic can end up fighting over the same worker: one just sent it to build a farm, while the other looks at the snapshot and thinks it’s still idle.

g.order_of(u) solves this: until the snapshot catches up, it uses the new order from the receipt. To decide whether a unit is idle, use g.order_of(u), not u.order. g.idle_workers() already excludes workers that were given a job this tick.

Latency tiers

TierChannelLatencyUsed for
0Pushed snapshot + event streamAbout 0.4 ms per read; fresh data every 50 msAll “observe” APIs
1Fast laneAbout 1 frame; median 0.06 ms with 6 processes in parallelAll commands and queries (SDK default)
2Control channel20 ~ 40 msFallback, and a few UI-type operations (game speed, speech bubbles, messages)
3Gateway (WebSocket / JSON)Tier 1 + about 1 msAny language, browsers, LLMs, programs on another machine

Every API in the API reference is labeled with the tier it uses.