Skip to content

Commit 733cec4

Browse files
authored
feat: merge the asobi_lua runtime into asobi (#339)
Moves the Lua runtime, bots and their tests out of widgrensit/asobi_lua and into this repo as an in-tree subsystem. Module names are unchanged, so a game's Lua scripts and any Erlang caller keep working against the same API. - src/lua/ holds the runtime, src/lua/bots/ the bot processes, include/ the bots header, test/fixtures/lua/ the script fixtures. - luerl becomes a direct dep and an entry in asobi.app.src applications. - asobi_lua_app is gone; asobi_sup now supervises the game-config load and asobi_lua_sup as its last two children, which is exactly where they ran when asobi_lua was a separate application started after asobi. - asobi_lua_env keeps reading the legacy `{asobi_lua, ...}` app-env keys and falls back to `{asobi, ...}`, so no existing sys.config silently goes dead now that the asobi_lua application no longer exists. - guides: the lua-scripting / lua-bots stubs are replaced by the real content they pointed at; sandbox, trust-model and self-hosting move in; asobi_lua's known-limitations lands as security-lua-known-limitations.md so it does not overwrite the core page of the same name.
1 parent 14f2f41 commit 733cec4

94 files changed

Lines changed: 14268 additions & 37 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/nightly.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ jobs:
7676
prop_phase_timer_monotonic,\
7777
prop_reconnect_lifecycle,\
7878
prop_chat_zone_routing,\
79-
prop_matchmaker_fill_strategy
79+
prop_matchmaker_fill_strategy,\
80+
prop_lua_bridge_input_threading,\
81+
prop_lua_error_containment
8082
8183
- name: Run snapshot round-trip CT
8284
run: rebar3 ct --suite=test/prop_zone_snapshot_roundtrip_SUITE

guides/lua-bots.md

Lines changed: 217 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,219 @@
11
# Bots
22

3-
Bots are AI-controlled players that join matches and worlds alongside real
4-
players. They are implemented and scripted by
5-
[asobi_lua](https://github.com/widgrensit/asobi_lua) — asobi itself has no bot
6-
code, so asobi_lua's documentation is the reference and this page points to it
7-
rather than keeping a copy that drifts.
8-
9-
- [Lua bots](https://github.com/widgrensit/asobi_lua/blob/main/guides/lua-bots.md)
10-
— enabling bots per game mode, the `bots` config map, and writing bot scripts
11-
- [Lua scripting](https://github.com/widgrensit/asobi_lua/blob/main/guides/lua-scripting.md)
12-
— the callbacks a bot script implements
13-
14-
See also [Lua Scripting](lua-scripting.md) for the runtime itself.
3+
Asobi includes built-in bot support. Bots run as server-side processes that
4+
join matches as regular players -- no fake clients, no network overhead. The
5+
AI logic runs in the same tick loop as the game.
6+
7+
## When to use bots
8+
9+
- Fill empty slots so matches start immediately instead of waiting for a full lobby.
10+
- A tutorial or single-player sandbox with scripted opponents.
11+
- Load-testing your tick loop without spawning real WebSocket sessions.
12+
- Replay / record-and-replay testing.
13+
14+
## How It Works
15+
16+
1. A player queues for matchmaking
17+
2. If no match is found within the configured wait time, Asobi adds bots
18+
3. Bots join the match like regular players
19+
4. Each tick, the bot calls a `think()` function to decide its input
20+
5. Bot input goes through the same `handle_input` path as real players
21+
22+
## Configuration
23+
24+
### Lua (Docker)
25+
26+
Enable bots by adding `bots` to your match script globals and a `names`
27+
list to your bot script:
28+
29+
```lua
30+
-- match.lua
31+
match_size = 4
32+
max_players = 8
33+
strategy = "fill"
34+
bots = { script = "bots/chaser.lua", min_players = 4 }
35+
```
36+
37+
`bots.min_players` is optional and defaults to `match_size`. `bots.enabled`
38+
is also optional and defaults to `true` (set it to `false` to keep the
39+
table around, e.g. to declare `min_players`, while disabling bot-fill).
40+
41+
```lua
42+
-- bots/chaser.lua
43+
names = {"Spark", "Blitz", "Volt", "Neon", "Pulse"}
44+
45+
function think(bot_id, state)
46+
-- AI logic here
47+
end
48+
```
49+
50+
The platform reads `names` from your bot script at runtime. Bot names are
51+
prefixed with `bot_` (e.g., `bot_Spark`).
52+
53+
The spawner checks the queue every 8 seconds (a fixed interval, not tunable) and
54+
fills a waiting match with bots up to the mode's `min_players`, capped at
55+
`max_players` so a small `match_size`/`max_players` mode never overshoots into
56+
a second, bot-only match. Both settings below live in the game mode's `bots`
57+
map — there are no bot environment variables.
58+
59+
### Erlang (sys.config)
60+
61+
For Erlang OTP projects, configure bots in `sys.config`:
62+
63+
```erlang
64+
{game_modes, #{
65+
~"arena" => #{
66+
module => {lua, "game/match.lua"},
67+
match_size => 4,
68+
bots => #{
69+
enabled => true,
70+
min_players => 4,
71+
script => <<"game/bots/chaser.lua">>
72+
}
73+
}
74+
}}
75+
```
76+
77+
Bot names are read from the bot script's `names` global. If not defined,
78+
defaults to `["Spark", "Blitz", "Volt", "Neon", "Pulse"]`.
79+
80+
## Writing a Bot AI Script
81+
82+
A bot script defines a single function: `think(bot_id, state)`. It receives
83+
the current game state and returns an input table -- the same format a real
84+
player would send. That is the whole callback surface: a bot script has no
85+
`on_join` / `on_leave` / `on_message` hooks; it only ever produces the next
86+
input from the current state (plus an optional `names` list, below).
87+
88+
Since the bot only decides from `state`, difficulty is a property of the
89+
script, not a config knob: throttle a reaction-time delay or degrade the target
90+
selection by keying private per-bot state off `bot_id` in a module-level table.
91+
92+
```lua
93+
-- game/bots/chaser.lua
94+
95+
function think(bot_id, state)
96+
local players = state.players or {}
97+
local me = players[bot_id]
98+
if not me then return {} end
99+
100+
-- Find nearest enemy
101+
local target = find_nearest(bot_id, me, players)
102+
if not target then
103+
return wander()
104+
end
105+
106+
-- Chase and shoot
107+
local dist = distance(me, target)
108+
return {
109+
right = target.x > me.x,
110+
left = target.x < me.x,
111+
down = target.y > me.y,
112+
up = target.y < me.y,
113+
shoot = dist < 200,
114+
aim_x = target.x,
115+
aim_y = target.y
116+
}
117+
end
118+
119+
function find_nearest(bot_id, me, players)
120+
local nearest, min_dist = nil, 99999
121+
for id, p in pairs(players) do
122+
if id ~= bot_id and p.hp and p.hp > 0 then
123+
local d = distance(me, p)
124+
if d < min_dist then
125+
nearest, min_dist = p, d
126+
end
127+
end
128+
end
129+
return nearest
130+
end
131+
132+
function distance(a, b)
133+
local dx = (a.x or 0) - (b.x or 0)
134+
local dy = (a.y or 0) - (b.y or 0)
135+
return math.sqrt(dx * dx + dy * dy)
136+
end
137+
138+
function wander()
139+
return {
140+
right = math.random(2) == 1,
141+
left = math.random(2) == 1,
142+
down = math.random(2) == 1,
143+
up = math.random(2) == 1,
144+
shoot = false
145+
}
146+
end
147+
```
148+
149+
## Multiple Bot Types
150+
151+
Create different AI scripts for different playstyles:
152+
153+
```
154+
game/bots/
155+
├── chaser.lua -- rushes nearest player
156+
├── sniper.lua -- stays back, long range
157+
├── healer.lua -- supports teammates
158+
└── camper.lua -- holds position, ambushes
159+
```
160+
161+
Currently, all bots in a game mode use the same script. To vary behavior,
162+
add randomization inside your `think()` function:
163+
164+
```lua
165+
local STRATEGIES = { "aggressive", "defensive", "random" }
166+
167+
function think(bot_id, state)
168+
-- Use bot_id hash to pick consistent strategy per bot
169+
local strategy = STRATEGIES[(#bot_id % #STRATEGIES) + 1]
170+
171+
if strategy == "aggressive" then
172+
return chase(bot_id, state)
173+
elseif strategy == "defensive" then
174+
return defend(bot_id, state)
175+
else
176+
return wander()
177+
end
178+
end
179+
```
180+
181+
## Default AI
182+
183+
If no bot script is configured, bots use a built-in default AI that:
184+
185+
- Finds the nearest living enemy
186+
- Moves toward them
187+
- Shoots when within range (200 units)
188+
- Adds slight aim randomization
189+
- Wanders randomly if no targets are alive
190+
191+
This works for most arena-style games out of the box.
192+
193+
## Auto Boon Pick and Voting
194+
195+
Bots automatically handle game phases:
196+
197+
- **Boon pick**: Bots pick the first available option immediately
198+
- **Voting**: Bots cast a random vote after a 1-3 second delay
199+
200+
This behavior is built-in and doesn't require any bot script code.
201+
202+
## Bot IDs
203+
204+
Bot player IDs are prefixed with `bot_` followed by their display name
205+
(e.g., `bot_Spark`, `bot_Blitz`). Your game logic can check for bots:
206+
207+
```lua
208+
function is_bot(player_id)
209+
return string.sub(player_id, 1, 4) == "bot_"
210+
end
211+
```
212+
213+
Clients receive bot players in the normal game state. Whether to show them
214+
differently (e.g., "AI" tag) is up to the client.
215+
216+
## Next steps
217+
218+
- [Lua scripting](lua-scripting.md) - the `game.*` API a bot's `think` shares with match logic.
219+
- [Trust model](security-trust-model.md) - a bot's `think` runs bounded, like any callback.

0 commit comments

Comments
 (0)