Docs / Lua / Bots

Lua bots

Server-side AI players that fill empty slots, drive tutorials, or load-test your game. Bots run inside the match alongside humans — no network, no WebSocket — and share the same game.* surface as your game logic.

When to use bots

  • Fill empty slots so matches start immediately.
  • Tutorial / single-player sandbox with scripted opponents.
  • Load test your tick loop without spawning real WebSocket sessions.
  • Replay / record-and-replay testing.

Writing a bot

A bot script exports a top-level think(bot_id, state) function. Asobi calls it each bot tick (100ms) with the latest match state for that bot.

-- bots/random_player.lua
-- Optional: advertise display names the platform reads back
names = {"Spark", "Blitz", "Volt", "Neon", "Pulse"}

function think(bot_id, state)
    -- state is the full match state as of the latest match.state broadcast.
    -- Return an input table to submit, or nil/{} to skip this tick.
    local players = state.players or {}
    local me = players[bot_id]
    if not me then return {} end

    -- wander randomly; real bots would pick targets, cast abilities, etc.
    return {
        right = math.random() < 0.5,
        left  = math.random() < 0.5,
        up    = math.random() < 0.5,
        down  = math.random() < 0.5,
    }
end

Wiring bots into a mode

Bots are configured per mode. In a Lua game, set bots as a top-level global in match.lua. In sys.config, use the bots key inside the mode:

-- match.lua
match_size  = 4
max_players = 4
bots = {
    script = "bots/random_player.lua"
}
{asobi, [
    {game_modes, #{
        ~"arena" => #{
            module     => my_arena,
            match_size => 4,
            bots       => #{
                enabled     => true,
                min_players => 4,
                script      => ~"bots/random_player.lua"
            }
        }
    }}
]}

When enabled = true and the queue for that mode is under min_players, asobi_bot_spawner fills the shortfall with bots that are added to the matchmaker like regular players.

Bot callbacks

  • think(bot_id, state) — required. Called each bot tick. Return an input map or {}.
  • names — optional top-level table of display-name strings the spawner picks from.

There is no on_join/on_leave/on_message surface: a bot is a plain input source, nothing more. Keep per-bot state by keying off bot_id.

Difficulty knobs

Common pattern: key private state off bot_id in a module-level table and add a reaction-time delay.

local mem = {}

function think(bot_id, state)
    local m = mem[bot_id] or { reaction = 0, skill = 1000 }
    m.reaction = m.reaction - 1
    if m.reaction > 0 then mem[bot_id] = m; return {} end

    m.reaction = math.max(1, math.floor(3000 / m.skill))
    mem[bot_id] = m
    return pick_action(bot_id, state)
end

Where next?