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 botSpawning 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 { ... }
endErlang
%% 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 ornil.on_message(state, view, msg)— optional; receives messagesgame.sendwould 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)
endLoad 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) ].