Write your game logic in Lua instead of Erlang. Asobi runs Lua scripts inside the BEAM via Luerl, giving you the fault tolerance and concurrency of OTP with a language game developers already know.
No Erlang knowledge required. No compilation step. Just Lua files and Docker.
The fastest way to get started -- no Erlang toolchain needed:
mkdir my_game && cd my_game
mkdir -p lua/botsCreate your match script:
-- lua/match.lua
-- Game mode config
match_size = 2
max_players = 8
strategy = "fill"
function init(config)
return {
players = {},
tick_count = 0
}
end
function join(player_id, state)
state.players[player_id] = {
x = 400, y = 300, hp = 100, score = 0
}
return state
end
function leave(player_id, state)
state.players[player_id] = nil
return state
end
function handle_input(player_id, input, state)
local p = state.players[player_id]
if not p then return state end
if input.right then p.x = p.x + 5 end
if input.left then p.x = p.x - 5 end
if input.down then p.y = p.y + 5 end
if input.up then p.y = p.y - 5 end
state.players[player_id] = p
return state
end
function tick(state)
state.tick_count = state.tick_count + 1
return state
end
function get_state(player_id, state)
return {
players = state.players,
tick_count = state.tick_count
}
endCreate a docker-compose.yml:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: my_game_dev
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
asobi:
image: ghcr.io/widgrensit/asobi_lua:latest
depends_on:
postgres: { condition: service_healthy }
ports:
- "8080:8080"
volumes:
- ./lua:/app/game:ro
environment:
ASOBI_DB_HOST: postgres
ASOBI_DB_NAME: my_game_devStart it:
docker compose up -dThat's it. Your game is running. Asobi reads your Lua scripts from the
mounted volume, discovers the game mode from match.lua, and handles
everything else -- database, authentication, matchmaking, WebSockets.
For games with more than one mode, add a config.lua manifest:
-- lua/config.lua
return {
arena = "arena/match.lua",
ctf = "ctf/match.lua"
}my_game/
├── lua/
│ ├── config.lua
│ ├── arena/
│ │ └── match.lua
│ └── ctf/
│ └── match.lua
└── docker-compose.yml
Each match script declares its own config as globals. When config.lua
exists, Asobi reads it instead of looking for a top-level match.lua.
When there is no config.lua, a single match.lua is loaded as the
"default" game mode.
Declare your game mode settings as globals at the top of your match script. Asobi reads these at startup before calling any callbacks.
match_size = 4 -- required: min players to start
max_players = 10 -- optional: max per match (defaults to match_size)
strategy = "fill" -- optional: "fill" or "skill_based"
bots = { script = "bots/ai.lua" } -- optional: enable bot filling| Global | Required | Default | Description |
|---|---|---|---|
match_size |
yes | -- | Minimum players needed to start a match |
max_players |
no | match_size |
Maximum players per match |
strategy |
no | "fill" |
Matchmaking strategy |
bots |
no | none | Bot configuration (see Bots) |
lazy_zones |
no | auto | On-demand zone loading (auto-enabled for grids > 100) |
zone_idle_timeout |
no | 30000 | Milliseconds before an idle zone is reaped |
max_active_zones |
no | 10000 | Maximum concurrent zones in memory |
spatial_grid_cell_size |
no | none | Cell size for spatial grid indexing (enables grid acceleration) |
cold_tick_divisor |
no | 10 | Tick rate divisor for cold (unoccupied) zones |
If you're building an Erlang OTP application, add asobi_lua as a
dependency in your rebar.config:
{deps, [
{asobi_lua, {git, "https://github.com/widgrensit/asobi_lua.git", {tag, "v0.1.0"}}}
]}.Configure Lua game modes in your sys.config:
{asobi, [
{game_modes, #{
~"arena" => #{
module => {lua, "game/match.lua"},
match_size => 4,
max_players => 8
}
}}
]}The Lua config loader only runs when a game directory with scripts exists.
Erlang projects with their own sys.config are completely unaffected.
Every Lua match script must define these functions:
Called once when a match is created. Returns the initial game state table.
function init(config)
return {
players = {},
arena_w = config.arena_w or 800,
arena_h = config.arena_h or 600
}
endCalled when a player joins. Returns the updated state.
function join(player_id, state)
state.players[player_id] = {
x = math.random(state.arena_w),
y = math.random(state.arena_h),
hp = 100
}
return state
endCalled when a player leaves. Returns the updated state.
function leave(player_id, state)
state.players[player_id] = nil
return state
endCalled when a player sends input via WebSocket. The input table contains
whatever the client sent. Returns the updated state.
function handle_input(player_id, input, state)
local p = state.players[player_id]
if not p or p.hp <= 0 then return state end
-- Movement
if input.right then p.x = p.x + p.speed end
if input.left then p.x = p.x - p.speed end
-- Shooting
if input.shoot and input.aim_x then
table.insert(state.projectiles, {
x = p.x, y = p.y,
vx = input.aim_x - p.x,
vy = input.aim_y - p.y,
owner = player_id
})
end
state.players[player_id] = p
return state
endCalled every tick (default 10 times per second). Advance your simulation here. Returns the updated state.
To signal that the match is finished, set _finished and _result on the
state:
function tick(state)
state.time_elapsed = state.time_elapsed + 1
if state.time_elapsed >= 900 then -- 90 seconds at 10 ticks/sec
state._finished = true
state._result = {
status = "completed",
winner = find_winner(state)
}
end
return state
endCalled every tick for each player. Returns the state visible to that player. Use this for fog-of-war, hiding other players' data, etc.
function get_state(player_id, state)
return {
phase = "playing",
players = state.players,
time_remaining = 900 - state.time_elapsed
}
endCalled after each tick. Return a vote configuration table to start a player
vote, or nil to skip. Votes can be triggered at any point during gameplay -
between rounds, after a boss kill, when a player levels up, or any other
game event.
function vote_requested(state)
if state.phase == "vote_pending" then
return {
template = "next_map",
options = {
{ id = "forest", label = "Forest" },
{ id = "desert", label = "Desert" },
{ id = "snow", label = "Snow" }
},
method = "plurality",
window_ms = 15000
}
end
return nil
endMid-game example (roguelike ability choice):
function vote_requested(state)
if state.pending_vote then
local vote = state.pending_vote
state.pending_vote = nil
return vote
end
return nil
end
function tick(state)
-- Trigger a vote when party reaches XP threshold
if state.party_xp >= state.next_level_xp and not state.pending_vote then
state.pending_vote = {
template = "choose_ability",
options = random_abilities(3),
method = "plurality",
window_ms = 15000
}
end
return state
endThe game keeps running while a vote is active. Multiple votes can run simultaneously.
Called when a vote completes. result.winner contains the winning option ID.
function vote_resolved(template, result, state)
if template == "next_map" then
state.next_map = result.winner
end
return state
endSplit your game into multiple files using Lua's require(). Asobi
automatically sets package.path to your script's directory.
game/
├── match.lua
├── physics.lua
├── boons.lua
└── bots/
├── chaser.lua
└── sniper.lua
In match.lua:
local physics = require("physics")
local boons = require("boons")
function tick(state)
state = physics.move_projectiles(state)
state = physics.check_collisions(state)
return state
endIn physics.lua:
local M = {}
function M.move_projectiles(state)
for i, p in ipairs(state.projectiles or {}) do
p.x = p.x + p.vx
p.y = p.y + p.vy
end
return state
end
function M.check_collisions(state)
-- collision detection logic
return state
end
return MSet _finished = true and _result on your state table in tick():
function tick(state)
if game_over(state) then
state._finished = true
state._result = {
status = "completed",
standings = build_standings(state),
winner = find_winner(state)
}
end
return state
endThe _result table is sent to all players via the match.finished WebSocket
event. Structure it however you like -- clients will receive it as JSON.
Your Lua scripts have access to:
- Standard Lua:
table,string,math,pairs,ipairs,type,tostring,tonumber, etc. math.random(n): Random integer 1..n (uses Erlang'srandmodule)math.sqrt(n): Square rootrequire(module): Load other Lua files from your game directory
For safety, filesystem and OS functions (io, os.execute, loadfile) are
not available. Your scripts run sandboxed inside the BEAM.
For persistent or large-area games (MMOs, open worlds), use world mode instead of match mode. World scripts support zone lifecycle and terrain features.
Set zone globals at the top of your world script:
match_size = 1
max_players = 100
lazy_zones = true -- load zones on demand
zone_idle_timeout = 60000 -- reap idle zones after 60s
max_active_zones = 500 -- cap concurrent zones
spatial_grid_cell_size = 64 -- spatial grid cell size for fast queries
cold_tick_divisor = 5 -- tick slower in unoccupied zonesReturn a terrain provider module from terrain_provider(). The provider
supplies compressed chunk data for each zone coordinate.
function terrain_provider(config)
return {
module = "my_terrain_provider",
args = { tileset = "overworld" }
}
endReturn nil to disable terrain.
function on_zone_loaded(cx, cy, state)
-- Called when a zone is lazily loaded
local zone_state = { biome = "plains", spawned = false }
return zone_state, state
end
function on_zone_unloaded(cx, cy, state)
-- Called when a zone is reaped after idle timeout
return state
endInside your game scripts, query terrain via the game.terrain namespace:
-- Get compressed chunk data for a coordinate
local result = game.terrain.get_chunk(3, 7)
-- Preload chunks around the player
game.terrain.preload({
{ cx = 3, cy = 7 },
{ cx = 4, cy = 7 },
{ cx = 3, cy = 8 }
})Query entities in the current zone by position. These use the zone's spatial
grid when spatial_grid_cell_size is set, falling back to brute-force scan.
-- Find all entities within radius of a point
local nearby = game.spatial.query_radius(100, 200, 50)
for _, hit in ipairs(nearby) do
print(hit.id, hit.x, hit.y)
end
-- Find all entities inside a rectangle
local in_area = game.spatial.query_rect(0, 0, 400, 300)Both return a list of {id, x, y} tables.
The entity-table variants (game.spatial.query_radius(entities, x, y, radius))
still work for client-side filtering without a zone process.
- Bots -- add AI-controlled players to your game
- Configuration -- all Asobi configuration options
- WebSocket Protocol -- client-server message format