Writing bots
A bot is a Python class with one method that matters: given the game's state for this tick, return what to press. Everything else (connecting, waiting for ticks, holding a pad, logging into the game's console) is the SDK's job.
from ibf import Action, Bot, Dir, State, run
class Walker(Bot):
name = "walker"
def on_tick(self, state: State) -> Action:
return Action(move=Dir.RIGHT)
if __name__ == "__main__":
run(Walker, pad=0)Run it with ibf run walker.py --pad 0, or python walker.py. A file can define one Bot
subclass; ibf run and ibf match find it, whatever its __main__ block says.
The game in brief
Two to four ships, black or white, move only on tiles of the other colour. A shot flips the tiles it crosses to the shooter's walkable colour and kills an enemy ship it reaches. Each ship holds a small clip of shots that refills over time; holding a shoot button charges a wider shot. In versus, a kill ends the round, and a match is first to three rounds. Players 1 and 2 (pads 0 and 1) are black and white; in two versus two, players 1 and 2 are the black team.
The tick
The game steps its simulation 60 times a second. After each step the framework publishes
a snapshot, and run calls your bot:
on_event(event)for each event since the last tick (a ship died, a round ended, ...);- the matching callbacks:
on_match_start(state),on_round_start(state, number),on_round_end(state, winner),on_match_end(state, winner); on_tick(state), whoseActionbecomes the pad's state until the next tick.
A bot that takes longer than a tick only misses ticks: the next call gets the newest state
(state.skipped counts what it missed), never a queue of old ones. Outside play (menus, the
pause menu, game over) the pad stays neutral, so a bot cannot wander the menus by accident;
menus = True on the class lets its input through there too.
on_connect(info) comes first, once connected; on_disconnect(reason) last, when the game
closes. An exception in a callback is logged and costs that tick, not the bot.
While it runs, a bot has: self.state (the newest state), self.log (a logger that writes
to your terminal and to the game's console as bot:<name>), self.input (timed presses),
self.draw, self.stats, self.game (commands), self.controls and self.pad.
The state
State is a read-only view of one snapshot. The main parts:
state.tick, state.time |
the simulation step's number, and the round's clock in seconds |
state.phase, state.in_match |
Phase.MENU, Phase.PLAYING, Phase.ROUND_OVER, ...; whether a match is on |
state.me, state.enemies, state.nearest_enemy() |
your ship (the pad's player), the living ships of the other colour |
state.ships, state.players |
every ship; the ships by player index |
state.projectiles, state.pickups |
shots in flight; pickups on the board |
state.pickup_spawns, state.spawn_markers |
where pickups come next, and in how long; in arcade, where enemies are about to appear (1.5 s ahead) |
state.board |
the tiles, and path helpers (below) |
state.match |
the map, its wrapping, rounds to win, the round's state |
state.events |
what happened since the last tick |
A Ship has pos and velocity (in tiles, the board's centre at 0, +y up), tile,
color, team, alive, ammo and max_ammo, fast_ammo (red shots), charging,
score, and the power-up timers. A Projectile has pos, dir, speed, kind, owner
and team. Coordinates are Vec2 (+, -, *, length, distance, normalized).
The board's tiles are (x, y) pairs counted from the lower left. Its helpers know the
rules:
board.tile(x, y) |
Tile.WHITE, Tile.BLACK or Tile.NONE (a hole, or off the board) |
board.walkable(tile, color) |
whether a ship of that colour may stand there |
board.next_step(a, b, for_color) |
the first step of a shortest walk from a to b, or Dir.NONE |
board.clear_lane(a, b) |
the direction from a to b when a shot from a reaches b, or None |
board.raycast(tile, dir) |
the tiles a shot from tile crosses |
board.array |
the tiles as a NumPy array, row 0 at the bottom (with NumPy installed) |
On a map that wraps (state.match.wrap), ships and shots leave one edge and come back on
the other side; the helpers follow that.
The Python API reference lists every name, and schema.md every field of the snapshot underneath.
Actions
on_tick returns an Action: the pad's state for this tick, in game terms.
Action(move=Dir.UP_LEFT) # 8-way movement at full speed
Action(move=Vec2(0.3, -1.0)) # any direction
Action(move=Dir.LEFT, shoot=Dir.UP) # a held shot: holding charges it
Action.idle() # nothing pressedA shot is up, down, left or right. Since an action is held, a quick shot needs a press and a release: the input sequencer does that over the next ticks, on top of the action you return.
self.input.tap(Dir.RIGHT) # a quick shot (held for min_press_ticks, then released)
self.input.charge(Dir.UP, ticks=40) # hold to charge, then release
if not self.input.busy:
... # nothing waitingActions map onto the game's controls as configured ([controls], see
Configuration); Action.raw(Pad(...)) sets the pad's buttons and sticks
directly.
Drawing over the game
self.draw is the tick's drawing, shown over the board until the next tick replaces it.
World coordinates are tiles, like the state's.
self.draw.line(me.pos, foe.pos, "#ff3355", width=2)
self.draw.circle(me.pos, 0.6, "cyan")
self.draw.tile(x, y, (1, 0, 0, 0.4), fill=True) # a tile, as (r, g, b, a) in 0-1
self.draw.path(tiles, "lime") # through tile centres
self.draw.text(foe.pos, "target", "white", size=14)
self.draw.heatmap(danger, palette="inferno", alpha=0.35) # a board-sized grid of numbers
self.draw.screen.text((12, 40), f"tick {state.tick}") # in pixelsColours are "#rrggbb", "#rrggbbaa", CSS names or (r, g, b[, a]) tuples. The drawing is
cleared before each on_tick; self.draw.retain = True keeps it. F4 hides the panels;
overlay.drawings off in the console hides every bot's drawings.
Parameters and stats
Parameters become controls in the overlay's Bots panel, so you can tune a bot while it plays:
class Hunter(Bot):
aggression = Param.float(0.6, 0.0, 1.0, help="how eagerly to close in")
show = Param.bool(True)
style = Param.enum(["calm", "wild"], "calm")
def on_param_changed(self, name, value):
self.log.info("%s is now %s", name, value)Each reads as its value (self.aggression). Param.int, Param.button() and
Param.color() exist too. Stats are numbers or short texts the panel shows; a plotted one
gets a sparkline:
self.stats["distance"] = Stat(round(d, 1), plot=True)
self.stats["mode"] = "hunting"Controlling the game
self.game runs console commands and drives the menus (the Navigator):
self.game.start_match("versus", players=1, map=0)
self.game.quit() # back to the main menu
self.game.command("status") # any console command; `help` lists them
self.game.pause()
self.game.step(1)
self.game.resume()Each blocks until done. While ibf match runs a match it holds the match lock, and the
bots' commands that change the game are refused.
A bot's process
A bot is a process of its own, in any Python (32- or 64-bit), with any packages: NumPy,
PyTorch and so on. A crash or a hang in it never stops the game; its pad goes neutral within
a quarter of a second. Several bots can play at once, one pad each (up to four). Lower down,
ibf.connect() gives a Client for tools that only read the state; see the
API reference.
A bot can also play a game on another machine, or from Linux, over TCP: ibf run mybot.py --url tcp://gamehost:47800 --token <token> (or the environment variables IBF_URL and
IBF_TOKEN), when that game listens ([bridge.tcp] in the configuration).
Nothing in the bot changes. The Linux wheel (ibf-<version>-py3-none-linux_x86_64.whl)
speaks TCP only; the arena's bot containers use it (the arena).
One thing costs more over TCP: the calls that read the host's state between ticks ask the
host first, a round trip (up to a few milliseconds over a network), so that they are as up
to date as over shared memory: client.pad, client.pads(), client.run_mode,
client.sim_paused, client.controls and client.info. Read them once, or once a second,
not every tick: the snapshot itself (state) and self.controls carry what a tick needs.