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 is a Lua file exporting a think function. Asobi calls it each bot tick (default 5 Hz).

-- bots/random_player.lua
local bot = {}

function bot.on_join(state, view)
    -- Called once when the bot joins the match.
    return { targeting = nil }
end

function bot.think(state, view)
    -- view is what get_state(bot_id, match_state) returned.
    -- Return an input table to submit, or nil to skip this tick.
    if view.phase ~= "active" then return nil end

    local enemies = enemies_in_sight(view)
    if #enemies == 0 then
        return { action = "move", x = math.random(-5, 5), y = math.random(-5, 5) }
    end

    local target = enemies[1]
    state.targeting = target.id
    return { action = "attack", target = target.id }
end

function bot.on_leave(_state, _view) end

return bot

Spawning a bot into a match

Lua

-- Lua (from your game module)
function game.init(config)
    -- Add 3 bots to fill the arena
    for i = 1, 3 do
        game.bots.add("random_player", {
            display_name = "Bot " .. i,
            skill = 1000,
        })
    end
    return { ... }
end

Erlang

%% Erlang API
{ok, _BotId} = asobi_bot:add(MatchPid, #{
    script       => <<"random_player">>,
    display_name => <<"Bot Alpha">>,
    skill        => 1000
}).

Bot callbacks

  • on_join(state, view) — called once; returns the bot's private state.
  • think(state, view) — called per bot tick. Return an input table or nil.
  • on_message(state, view, msg) — optional; receives messages game.send would have sent to a human.
  • on_leave(state, view) — optional; cleanup.

Configuration

{asobi_lua, [
    {bot_dir,      <<"./bots">>},
    {bot_tick_ms,  200},                 %% 5 Hz default
    {max_bots_per_match, 8}
]}

Difficulty knobs

Common pattern: add a reaction-time delay based on “skill” metadata.

function bot.think(state, view)
    state.reaction_countdown = (state.reaction_countdown or 0) - 1
    if state.reaction_countdown > 0 then return nil end

    -- simulate decision-making delay (higher skill = faster reactions)
    state.reaction_countdown = math.max(1, math.floor(30 / state.skill * 10))

    return pick_action(view)
end

Load testing with bots

To pressure-test a match, spawn N bots that do trivial actions and let the tick loop run. Because bots skip the WebSocket, you can saturate CPU with a few lines of config.

[ asobi_bot:add(MatchPid, #{script => <<"noop_bot">>}) || _ <- lists:seq(1, 100) ].

Where next?