diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea19b7c..91bf7fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,8 @@ name: CI # runtime into asobi, so lint, typecheck and the unit/CT suites all run there. # The Taure/erlang-ci delegation is gone with the source it analysed. What still # has to hold is that the alias release assembles and the image builds: if this -# goes red, ghcr.io/widgrensit/asobi_lua cannot be rebuilt. +# goes red, nothing is affected: this repository is archived and its image is +# no longer built. Kept only so the final state stays reproducible. on: push: diff --git a/AGENTS.md b/AGENTS.md index 51e415f..fefd9c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,13 @@ > **This repository is retired.** The Lua runtime was merged into > [widgrensit/asobi](https://github.com/widgrensit/asobi) (asobi#339) and is -> developed there. No Erlang source lives here any more. All that remains is the -> packaging that keeps `ghcr.io/widgrensit/asobi_lua` publishing as an alias for -> an asobi-only release, so existing self-hosters are not broken. +> developed there. No Erlang source lives here any more, and the repository is +> ARCHIVED and read-only. +> +> `ghcr.io/widgrensit/asobi_lua` is **no longer built or published**. It was +> renamed to `ghcr.io/widgrensit/asobi`. Tags already published keep working but +> receive no fixes; the last was built from asobi v0.71.0. Do not describe the +> old name as a live alias anywhere - it was, briefly, and it is not now. > > **Do not add code here.** Lua bridge, `game.*` API, bots, hot-reload, sandbox > and script validation all belong in `asobi/src/lua/`. The working agreement @@ -22,7 +26,7 @@ files, hot-reloaded in place with no restart. Apache-2.0, pre-1.0. - **asobi** (public library, Hex) - the game backend itself: auth, matches, matchmaker, leaderboards, economy, social, worlds, storage. Erlang authors depend on this directly. -- **asobi_lua** (this repo, public, `ghcr.io/widgrensit/asobi_lua`) - wraps +- **asobi_lua** (this repo, ARCHIVED; its image was renamed to `ghcr.io/widgrensit/asobi`) - wrapped the public `asobi` library with a Lua `game.*` API via Luerl. Depends on `asobi` + `luerl`. Lua integration code belongs **here**, never in `asobi`. - **asobi_engine** (private) - single-tenant hosted image; depends on BOTH diff --git a/README.md b/README.md index c0c0e69..3992d70 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ asobi +

+ # This repository is retired **The Lua runtime now lives in [widgrensit/asobi](https://github.com/widgrensit/asobi).** @@ -8,16 +12,28 @@ is developed there. This repository holds no Erlang source any more. **Your Lua game code is unaffected.** `match.lua`, `world.lua`, `config.lua` and the `game.*` API are unchanged. Nothing to rewrite, nothing to rename. -**Your Docker image is unaffected.** `ghcr.io/widgrensit/asobi_lua` keeps being -published from this repository, with the same tags, the same `bin/asobi_lua` -entrypoint, the same port and the same environment variables. It is now an alias: -the release it ships is built from `asobi`, which carries the Lua runtime. Nothing -to change in your compose file or deployment. +## The Docker image was renamed + +``` + old: ghcr.io/widgrensit/asobi_lua + new: ghcr.io/widgrensit/asobi +``` + +**`ghcr.io/widgrensit/asobi_lua` is no longer rebuilt.** Tags already published +keep working and are not going away, but they receive no fixes - including +security fixes. The last one was built from `asobi` v0.71.0. + +Change the image name in your compose file or manifest. Nothing else changes: +same tags, same ports, same environment variables. The image contents are the +same too - the game backend, the Lua runtime and the operator console. The Lua +runtime stopped being a separate application, so the old name described a part +rather than the whole. -New self-hosters should follow -[asobi's self-hosting guide](https://github.com/widgrensit/asobi/blob/main/guides/self-hosting.md). +New and existing self-hosters should follow +[asobi's self-hosting guide](https://github.com/widgrensit/asobi/blob/main/guides/self-hosting.md), +which covers the rename. -**Where to go:** +## Where to go | For | Go to | | --- | --- | @@ -26,240 +42,11 @@ New self-hosters should follow | Lua scripting docs | [asobi guides](https://github.com/widgrensit/asobi/tree/main/guides) and [asobi.dev/docs](https://asobi.dev/docs) | | Questions | [Discord](https://discord.gg/vYSfYYyXpu) | -This tracker is closed to new issues. Existing issues stay open and readable - -they are the record behind a lot of these decisions. - -The rest of this README is kept for reference and describes the pre-merge layout. - ---- - -

- asobi -

- -

asobi_lua

- -

- Open-source game backend. Write it in Lua. Hot-reload without restart. Apache-2. -

- -

- Docker - Release - CI - License -

- -

- Docs • - Live demo • - Discord • - Issues -

- -

- asobi_lua hot-reload: edit a Lua file, save, live match updates — no restart -
- Edit a Lua file. Save. Live match updates. No restart. Try it. -

- ---- - -## What is asobi_lua? - -A batteries-included multiplayer game backend you write in Lua, packaged as -a single Docker image. You get auth, matchmaking, rooms, leaderboards, economy, -chat, tournaments, voting, parties, phases and seasons, reconnection, and -WebSocket + REST out of the box — and SDKs for Godot, Defold, Unity, Unreal, -JS/TS, and Flutter. - -No Erlang knowledge required. Your match logic is a `.lua` file. Save it and -connected clients see the change — no restart, no redeploy. - -```lua --- lua/match.lua -match_size = 2 - -function init(config) - return { players = {}, tick_count = 0 } -end - -function join(player_id, state) - state.players[player_id] = { x = 400, y = 300, hp = 100 } - return state -end - -function handle_input(player_id, input, state) - local p = state.players[player_id] - if input.right then p.x = p.x + 5 end - if input.left then p.x = p.x - 5 end - if input.shoot then - game.broadcast("shot", { by = player_id, at = input.aim }) - end - return state -end - -function tick(state) - state.tick_count = state.tick_count + 1 - return state -end -``` - -That's a full playable room. Save the file, the match reloads in place. - -## Quick Start - -**1. Create the project layout** - -```bash -mkdir my_game && cd my_game -mkdir -p lua -# paste the match.lua above into lua/match.lua -``` - -**2. Bring up Postgres + asobi_lua** - -```yaml -# docker-compose.yml -services: - postgres: - image: postgres:17 - environment: { POSTGRES_USER: postgres, POSTGRES_PASSWORD: postgres, POSTGRES_DB: my_game } - healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 5s } - - asobi: - image: ghcr.io/widgrensit/asobi_lua:latest - depends_on: { postgres: { condition: service_healthy } } - ports: ["8084:8084"] - volumes: ["./lua:/app/game:ro"] - environment: { ASOBI_DB_HOST: postgres, ASOBI_DB_NAME: my_game } -``` - -```bash -docker compose up -d -``` - -**3. Register a player and join a match** - -```bash -# Register -curl -s localhost:8084/api/v1/auth/register \ - -H 'content-type: application/json' \ - -d '{"username":"alice","password":"hunter2!"}' -# → { "username": "alice", "player_id": "019de3...", "session_token": "wRqvop92/..." } - -# Queue for matchmaking -curl -s localhost:8084/api/v1/matchmaker \ - -H 'content-type: application/json' \ - -H 'authorization: Bearer wRqvop92/...' \ - -d '{"mode":"default","properties":{},"party":["019de3..."]}' -# → { "status": "pending", "ticket_id": "019de3..." } -``` - -Connect the WebSocket from any SDK below and the client is live. Edit -`lua/match.lua`, save, and the running match picks up the change — players -stay connected, state is preserved. - -## Why asobi - -- ⚡ **Hot-reload Lua** — push a fix at 11pm, your live match keeps playing. No restart, no dropped sockets. -- 🎮 **Every engine** — first-class SDKs for Godot, Defold, Unity, Unreal, plus JS/TS and Flutter. -- 🧠 **Batteries included** — matchmaker (fill + skill), rooms, economy, inventory, leaderboards, tournaments, chat, social, notifications, IAP, **voting with 4 methods** (plurality, ranked, approval, weighted), **phases**, **seasons**, reconnection. -- 🗺️ **Large-world ready** — spatial zones, lazy zone loading, terrain chunks, adaptive tick rates. Single-node by design; shard at the app level. -- 🚀 **83,000 msg/sec** sustained at 3,500 concurrent WebSockets, 4.4ms p50 RTT — see [benchmarks](https://github.com/widgrensit/asobi/blob/main/guides/benchmarks.md). -- 🛡️ **Apache-2, self-host** — use commercially, fork it, run it yourself. We will never relicense. [Exit guaranteed →](https://github.com/widgrensit/asobi/blob/main/guides/exit.md) -- 🇪🇺 **Made in the EU** — GDPR-ready, NIS2-aware, no US cloud lock-in. -- 🔒 **OTP fault tolerance** — one match crashing never touches any other match. No GC pauses during gameplay. - -## Client SDKs - -| Engine | Install | Docs | Sample | -|---|---|---|---| -| **Godot 4.x** | [widgrensit/asobi-godot](https://github.com/widgrensit/asobi-godot) | [guide](https://github.com/widgrensit/asobi-godot#readme) | [asobi-godot-demo](https://github.com/widgrensit/asobi-godot-demo) | -| **Defold** | [widgrensit/asobi-defold](https://github.com/widgrensit/asobi-defold) | [guide](https://github.com/widgrensit/asobi-defold#readme) | [asobi-defold-demo](https://github.com/widgrensit/asobi-defold-demo) | -| **Unity 2021.3+** | `com.asobi.sdk` (UPM via git URL) | [guide](https://github.com/widgrensit/asobi-unity#readme) | [asobi-unity-demo](https://github.com/widgrensit/asobi-unity-demo) | -| **Unreal 5** | [widgrensit/asobi-unreal](https://github.com/widgrensit/asobi-unreal) | [guide](https://github.com/widgrensit/asobi-unreal#readme) | — | -| **JS / TS** | [widgrensit/asobi-js](https://github.com/widgrensit/asobi-js) | [guide](https://github.com/widgrensit/asobi-js#readme) | — | -| **Flutter** | `dart pub add asobi` | [guide](https://github.com/widgrensit/asobi-dart#readme) | [asobi-flame-demo](https://github.com/widgrensit/asobi-flame-demo) | -| **Flame (Flutter)** | [widgrensit/flame_asobi](https://github.com/widgrensit/flame_asobi) | [guide](https://github.com/widgrensit/flame_asobi#readme) | [asobi-flame-demo](https://github.com/widgrensit/asobi-flame-demo) | - -## How it works - -``` - your Lua scripts (mounted at /app/game) - │ - ▼ - asobi_lua ── Luerl VM + bridge modules + bot runtime - │ - ▼ - asobi (library) ── OTP supervision, pg groups, rate limits, sessions - │ - ▼ - Nova + Kura + PostgreSQL -``` - -Every match and world runs as its own BEAM process under a supervisor. -Luerl executes your Lua inside the BEAM — no sub-process, no GC pauses. The -script runs in a hardened state with `os.execute`, `os.exit`, `dofile`, -`loadfile`, `load`, `loadstring`, `io`, and `package` removed; `require/1` -is replaced by an asobi_lua-controlled implementation that resolves names -relative to your `/app/game` directory and rejects `..` traversal. Every -callback runs under a wall-clock timeout so a runaway script can't wedge -the match loop. See [SECURITY.md](SECURITY.md#sandbox-model) for the full -sandbox contract. Hot reload swaps the Luerl module while match state stays -in the process heap. - -> [!NOTE] -> asobi_lua is pre-1.0. The API is stabilising; expect minor breaking changes -> until 1.0. We ship in lockstep with the [asobi library](https://github.com/widgrensit/asobi) -> (Hex.pm) and version SDKs against server tags. - -## Self-host, today - -Run the image wherever you like — Hetzner, Scaleway, Fly, Clever, a Raspberry -Pi, your laptop. The image is **~120MB**, cold starts in **<3s**, and holds -thousands of WebSockets on a single vCPU. Full deployment guide at -[asobi.dev/docs/deploy](https://asobi.dev/docs/deploy). - -A managed cloud at **asobi.dev** is opening later in 2026 — same binary, flat -per-container pricing, never CCU-based. [Learn more →](https://asobi.dev/cloud). - -## Migrating? - -- [**from Hathora**](https://github.com/widgrensit/asobi/blob/main/guides/migrate-from-hathora.md) — rooms → matches, serverless processes → container. Hathora shuts down 2026-05-05. -- [**from PlayFab**](https://github.com/widgrensit/asobi/blob/main/guides/migrate-from-playfab.md) — Titles, CloudScript, Virtual Currency mapped. -- [**from Nakama self-host**](https://github.com/widgrensit/asobi/blob/main/guides/migrate-from-nakama.md) — keep your Lua runtime, lose CockroachDB. - -## Documentation - -- [**Lua Scripting Guide**](guides/lua-scripting.md) — callbacks, state, modules, voting, world mode -- [**Bot AI Guide**](guides/lua-bots.md) — write bots that fill matches -- [**Self-hosting**](guides/self-hosting.md) — production deployment patterns: bake-into-image vs. atomic-rename live updates -- [**asobi engine docs**](https://github.com/widgrensit/asobi#readme) — architecture, REST API, WebSocket protocol, benchmarks - -## Community - -- 💬 [Discord](https://discord.gg/vYSfYYyXpu) — chat with the team and other devs -- 🗣️ [GitHub Discussions](https://github.com/widgrensit/asobi_lua/discussions) — Q&A, show-and-tell, RFCs -- 🐛 [Issues](https://github.com/widgrensit/asobi_lua/issues) — bug reports and feature requests -- 📦 [Releases](https://github.com/widgrensit/asobi_lua/releases) — changelog and release notes - -## Using asobi_lua as an Erlang library - -If you're already writing Erlang/OTP and want Lua scripting as a dep: - -```erlang -%% rebar.config -{deps, [ - {asobi_lua, {git, "https://github.com/widgrensit/asobi_lua.git", {tag, "v0.1.0"}}} -]}. -``` - -Configure game modes in your `sys.config` — see [guides/lua-scripting.md](guides/lua-scripting.md#using-with-erlang-projects). - -Game-server authors writing Erlang directly should depend on the core library at -[widgrensit/asobi](https://github.com/widgrensit/asobi) instead. - -## License +This repository is archived and read-only. Its issues stay readable - they are +the record behind a lot of these decisions - and `docs/adr/` keeps the two +architecture decisions that were made here and nowhere else. -Apache-2.0. See [LICENSE](LICENSE). +The guides that used to sit in this repo have been removed rather than left to +rot: they were older, shorter forks of the ones in `asobi`, and they still +taught the retired image. Use +[asobi's guides](https://github.com/widgrensit/asobi/tree/main/guides). diff --git a/guides/lua-bots.md b/guides/lua-bots.md deleted file mode 100644 index b59c6d7..0000000 --- a/guides/lua-bots.md +++ /dev/null @@ -1,219 +0,0 @@ -# Bots - -Asobi includes built-in bot support. Bots run as server-side processes that -join matches as regular players -- no fake clients, no network overhead. The -AI logic runs in the same tick loop as the game. - -## When to use bots - -- Fill empty slots so matches start immediately instead of waiting for a full lobby. -- A tutorial or single-player sandbox with scripted opponents. -- Load-testing your tick loop without spawning real WebSocket sessions. -- Replay / record-and-replay testing. - -## How It Works - -1. A player queues for matchmaking -2. If no match is found within the configured wait time, Asobi adds bots -3. Bots join the match like regular players -4. Each tick, the bot calls a `think()` function to decide its input -5. Bot input goes through the same `handle_input` path as real players - -## Configuration - -### Lua (Docker) - -Enable bots by adding `bots` to your match script globals and a `names` -list to your bot script: - -```lua --- match.lua -match_size = 4 -max_players = 8 -strategy = "fill" -bots = { script = "bots/chaser.lua", min_players = 4 } -``` - -`bots.min_players` is optional and defaults to `match_size`. `bots.enabled` -is also optional and defaults to `true` (set it to `false` to keep the -table around, e.g. to declare `min_players`, while disabling bot-fill). - -```lua --- bots/chaser.lua -names = {"Spark", "Blitz", "Volt", "Neon", "Pulse"} - -function think(bot_id, state) - -- AI logic here -end -``` - -The platform reads `names` from your bot script at runtime. Bot names are -prefixed with `bot_` (e.g., `bot_Spark`). - -The spawner checks the queue every 8 seconds (a fixed interval, not tunable) and -fills a waiting match with bots up to the mode's `min_players`, capped at -`max_players` so a small `match_size`/`max_players` mode never overshoots into -a second, bot-only match. Both settings below live in the game mode's `bots` -map — there are no bot environment variables. - -### Erlang (sys.config) - -For Erlang OTP projects, configure bots in `sys.config`: - -```erlang -{game_modes, #{ - ~"arena" => #{ - module => {lua, "game/match.lua"}, - match_size => 4, - bots => #{ - enabled => true, - min_players => 4, - script => <<"game/bots/chaser.lua">> - } - } -}} -``` - -Bot names are read from the bot script's `names` global. If not defined, -defaults to `["Spark", "Blitz", "Volt", "Neon", "Pulse"]`. - -## Writing a Bot AI Script - -A bot script defines a single function: `think(bot_id, state)`. It receives -the current game state and returns an input table -- the same format a real -player would send. That is the whole callback surface: a bot script has no -`on_join` / `on_leave` / `on_message` hooks; it only ever produces the next -input from the current state (plus an optional `names` list, below). - -Since the bot only decides from `state`, difficulty is a property of the -script, not a config knob: throttle a reaction-time delay or degrade the target -selection by keying private per-bot state off `bot_id` in a module-level table. - -```lua --- game/bots/chaser.lua - -function think(bot_id, state) - local players = state.players or {} - local me = players[bot_id] - if not me then return {} end - - -- Find nearest enemy - local target = find_nearest(bot_id, me, players) - if not target then - return wander() - end - - -- Chase and shoot - local dist = distance(me, target) - return { - right = target.x > me.x, - left = target.x < me.x, - down = target.y > me.y, - up = target.y < me.y, - shoot = dist < 200, - aim_x = target.x, - aim_y = target.y - } -end - -function find_nearest(bot_id, me, players) - local nearest, min_dist = nil, 99999 - for id, p in pairs(players) do - if id ~= bot_id and p.hp and p.hp > 0 then - local d = distance(me, p) - if d < min_dist then - nearest, min_dist = p, d - end - end - end - return nearest -end - -function distance(a, b) - local dx = (a.x or 0) - (b.x or 0) - local dy = (a.y or 0) - (b.y or 0) - return math.sqrt(dx * dx + dy * dy) -end - -function wander() - return { - right = math.random(2) == 1, - left = math.random(2) == 1, - down = math.random(2) == 1, - up = math.random(2) == 1, - shoot = false - } -end -``` - -## Multiple Bot Types - -Create different AI scripts for different playstyles: - -``` -game/bots/ -├── chaser.lua -- rushes nearest player -├── sniper.lua -- stays back, long range -├── healer.lua -- supports teammates -└── camper.lua -- holds position, ambushes -``` - -Currently, all bots in a game mode use the same script. To vary behavior, -add randomization inside your `think()` function: - -```lua -local STRATEGIES = { "aggressive", "defensive", "random" } - -function think(bot_id, state) - -- Use bot_id hash to pick consistent strategy per bot - local strategy = STRATEGIES[(#bot_id % #STRATEGIES) + 1] - - if strategy == "aggressive" then - return chase(bot_id, state) - elseif strategy == "defensive" then - return defend(bot_id, state) - else - return wander() - end -end -``` - -## Default AI - -If no bot script is configured, bots use a built-in default AI that: - -- Finds the nearest living enemy -- Moves toward them -- Shoots when within range (200 units) -- Adds slight aim randomization -- Wanders randomly if no targets are alive - -This works for most arena-style games out of the box. - -## Auto Boon Pick and Voting - -Bots automatically handle game phases: - -- **Boon pick**: Bots pick the first available option immediately -- **Voting**: Bots cast a random vote after a 1-3 second delay - -This behavior is built-in and doesn't require any bot script code. - -## Bot IDs - -Bot player IDs are prefixed with `bot_` followed by their display name -(e.g., `bot_Spark`, `bot_Blitz`). Your game logic can check for bots: - -```lua -function is_bot(player_id) - return string.sub(player_id, 1, 4) == "bot_" -end -``` - -Clients receive bot players in the normal game state. Whether to show them -differently (e.g., "AI" tag) is up to the client. - -## Next steps - -- [Lua scripting](lua-scripting.md) - the `game.*` API a bot's `think` shares with match logic. -- [Trust model](security-trust-model.md) - a bot's `think` runs bounded, like any callback. diff --git a/guides/lua-scripting.md b/guides/lua-scripting.md deleted file mode 100644 index bf3d936..0000000 --- a/guides/lua-scripting.md +++ /dev/null @@ -1,634 +0,0 @@ -# Lua Scripting - -Write your game logic in Lua instead of Erlang. Asobi runs Lua scripts -inside the BEAM via [Luerl](https://github.com/rvirding/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. - -## Quick Start with Docker - -The fastest way to get started -- no Erlang toolchain needed: - -```bash -mkdir my_game && cd my_game -mkdir -p lua/bots -``` - -Create your match script: - -```lua --- 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 - } -end -``` - -Create a `docker-compose.yml`: - -```yaml -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: - - "8084:8084" - volumes: - - ./lua:/app/game:ro - environment: - ASOBI_DB_HOST: postgres - ASOBI_DB_NAME: my_game_dev -``` - -Start it: - -```bash -docker compose up -d -``` - -That'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. - -### Multiple Game Modes - -For games with more than one mode, add a `config.lua` manifest: - -```lua --- 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. - -## Match Script Globals - -Declare your game mode settings as globals at the top of your match script. -Asobi reads these at startup before calling any callbacks. - -```lua -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 -guest_auth = true -- optional: allow anonymous guest play -registration = "closed" -- optional: signup posture -``` - -| 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](lua-bots.md)) | -| `guest_auth` | no | `false` | Enable anonymous guest play. Also requires an operator-supplied pepper; on iff both are present (asobi ADR 0004) | -| `registration` | no | `"open"` | Signup posture: `"open"`, `"oauth_only"` (no password signup), or `"closed"` (no new players at all). Omit it to keep whatever the release's `sys.config` sets | -| `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 | - -For a single-mode game these globals live in `match.lua`. In a multi-mode game -(a `config.lua` manifest that maps modes to match scripts), deployment-wide -settings such as `guest_auth` and `registration` go in `config.lua`, not the -per-mode scripts. - -## Using with Erlang Projects - -If you're building an Erlang OTP application, add `asobi_lua` as a -dependency in your `rebar.config`: - -```erlang -{deps, [ - {asobi_lua, {git, "https://github.com/widgrensit/asobi_lua.git", {tag, "v0.1.0"}}} -]}. -``` - -Configure Lua game modes in your `sys.config`: - -```erlang -{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. - -## Callbacks - -Every Lua match script must define these functions: - -### `init(config)` - -Called once when a match is created. Returns the initial game state table. - -```lua -function init(config) - return { - players = {}, - arena_w = config.arena_w or 800, - arena_h = config.arena_h or 600 - } -end -``` - -### `join(player_id, state)` - -Called when a player joins. Returns the updated state. - -```lua -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 -end -``` - -### `leave(player_id, state)` - -Called when a player leaves. Returns the updated state. - -```lua -function leave(player_id, state) - state.players[player_id] = nil - return state -end -``` - -### `handle_input(player_id, input, state)` - -Called when a player sends input via WebSocket. The `input` table contains -whatever the client sent. Returns the updated state. - -```lua -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 -end -``` - -### `tick(state)` - -Called 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: - -```lua -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 -end -``` - -### `get_state(player_id, state)` - -Called every tick for each player. Returns the state visible to that player. -Use this for fog-of-war, hiding other players' data, etc. - -```lua -function get_state(player_id, state) - return { - phase = "playing", - players = state.players, - time_remaining = 900 - state.time_elapsed - } -end -``` - -### `vote_requested(state)` (optional) - -Called 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. - -```lua -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 -end -``` - -Mid-game example (roguelike ability choice): - -```lua -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 -end -``` - -The game keeps running while a vote is active. Multiple votes can run -simultaneously. - -### `vote_resolved(template, result, state)` (optional) - -Called when a vote completes. `result.winner` contains the winning option ID. - -```lua -function vote_resolved(template, result, state) - if template == "next_map" then - state.next_map = result.winner - end - return state -end -``` - -## Modules and `require()` - -Split 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`: - -```lua -local physics = require("physics") -local boons = require("boons") - -function tick(state) - state = physics.move_projectiles(state) - state = physics.check_collisions(state) - return state -end -``` - -In `physics.lua`: - -```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 M -``` - -## Finishing a Match - -Set `_finished = true` and `_result` on your state table in `tick()`: - -```lua -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 -end -``` - -The `_result` table is sent to all players via the `match.finished` WebSocket -event. Structure it however you like -- clients will receive it as JSON. - -## Available Functions - -Your Lua scripts have access to: - -- **Standard Lua**: `table`, `string`, `math`, `utf8`, `bit32`, `pairs`, - `ipairs`, `next`, `type`, `tostring`, `tonumber`, `pcall`, `error`, - `assert`, `setmetatable`/`getmetatable`, `rawget`/`rawset`/`rawequal`/`rawlen` -- **Time helpers**: `os.clock`, `os.date`, `os.difftime`, `os.time` -- **`math.random(n)`**: integer in `[1, n]`. **`math.random(a, b)`**: - integer in `[a, b]`; an empty interval (`a > b`) raises, as in - standard Lua. Non-integer bounds are truncated toward zero (standard - Lua raises instead). The RNG is auto-seeded per match, so - **`math.randomseed` has no effect** — calling it is harmless, but - scripts cannot rely on seeded determinism. -- **`math.sqrt(n)`**: square root. Negative input returns `0.0`. -- **`require("foo.bar")`**: loads `foo/bar.lua` relative to your game - directory. Names are validated; `..`, `/`, and absolute paths are - rejected. Modules are cached, and `asobi_lua_match` clears the cache - on hot-reload so changes to required files pick up. -- **`game.log(level, message[, meta])`**: structured logging - see - [Logging](#logging) below. `print` is removed; use this instead. - -The following are removed from the Lua environment: - -- **OS escape hatches**: `os.execute`, `os.exit`, `os.getenv`, - `os.remove`, `os.rename`, `os.tmpname` -- **Code loaders**: `dofile`, `loadfile`, `load`, `loadstring` -- **I/O**: the entire `io` library (`io.open`, `io.read`, `io.write`, ...) -- **Package machinery**: `package` and the default `require`. Use the - asobi_lua-controlled `require/1` described above instead. - -Every Lua callback also runs under a wall-clock timeout. A `while true do end` -in any callback is killed when the budget elapses; the bridge logs a warning -and continues with the previous state. See [SECURITY.md](../SECURITY.md#sandbox-model) -for the full sandbox model and per-callback timeout limits. - -## World Mode: Large Sessions with Zones - -For persistent or large-area games (MMOs, open worlds), use world mode instead -of match mode. World scripts support zone lifecycle and terrain features. - -### Zone Configuration - -Set zone globals at the top of your world script: - -```lua -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 zones -``` - -### Terrain Provider (optional) - -Return a terrain provider module from `terrain_provider()`. The provider -supplies compressed chunk data for each zone coordinate. - -```lua -function terrain_provider(config) - return { - module = "my_terrain_provider", - args = { tileset = "overworld" } - } -end -``` - -Return `nil` to disable terrain. - -### Zone Lifecycle Callbacks (optional) - -```lua -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 -end -``` - -### Terrain API - -Inside your game scripts, query terrain via the `game.terrain` namespace: - -```lua --- 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 } -}) -``` - -### Spatial Queries (Zone-Based) - -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. - -```lua --- Find all entities within radius of a point -local nearby = game.spatial.query_radius(100, 200, 50) -for _, hit in ipairs(nearby) do - game.log("debug", "nearby entity", { id = hit.id, x = hit.x, y = 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. - -## Logging - -`game.log` writes a structured line through the server's logger, so it shows -up in the container's log stream and, on managed cloud, in the console log -viewer for your environment (filter on `game.log`): - -```lua -function handle_input(player_id, input, state) - game.log("info", "input received", { player = player_id, kind = input.kind }) - -- ... - return state -end -``` - -- Levels: `"debug"`, `"info"`, `"warn"`/`"warning"`, `"error"`. Anything - else returns `{ error = ... }`. -- `message` can be a string or any value (tables are rendered as JSON). - Messages are capped at 500 characters. -- `meta` is an optional table of key/values, capped at 2 KB. -- Logging is rate-limited (30 lines per second per match or zone, with a - node-wide cap of 300). Over budget, `game.log` returns `false` and the - line is dropped - so a log call in a tight tick loop degrades gracefully - instead of flooding the log stream. Self-hosting operators can tune both - budgets via `{asobi_lua, rate_limits}` if log-pipeline cost matters - - the defaults allow up to ~2.5 KB x 300 lines per second per node at - the ceiling. - -`print` does not exist in the sandbox; it was removed because it bypassed -the structured log stream. `game.log` is the supported way to see what your -server is doing. - -## Debugging Script Errors - -A runtime error in a callback never crashes the match: the server logs the -error, keeps the previous state, and carries on. From the client that looks -like the callback silently doing nothing. A common first-hour trap is -`state.counter = state.counter + 1` when `init` never set `counter`, which -fails every call with "bad arithmetic on nil". - -During development, set `ASOBI_DEV_ERRORS=true` on the container to have -each failing `handle_input` also send a `game.error` event to the player -whose input triggered it: - -```json -{"type": "game.error", "payload": { - "callback": "handle_input", - "script": "match.lua", - "message": "bad arithmetic + on nil, 1" -}} -``` - -Events are rate-limited to one per second per match. Leave the flag off in -production (it is off by default): error messages can reveal script -internals, and players should never see them. - -## Next Steps - -- [Bots](lua-bots.md) -- add AI-controlled players to your game -- [Self-hosting](self-hosting.md) -- production deployment and live updates -- [Configuration](https://asobi.dev/docs/configuration) -- all Asobi configuration options -- [WebSocket Protocol](https://asobi.dev/docs/protocols/websocket) -- client-server message format diff --git a/guides/security-known-limitations.md b/guides/security-known-limitations.md deleted file mode 100644 index 3d4c590..0000000 --- a/guides/security-known-limitations.md +++ /dev/null @@ -1,81 +0,0 @@ -# Known limitations - -The asobi_lua sandbox closes a deliberate set of attack surfaces -(documented in [Sandbox model](security-sandbox.md)). The list below is -the complement: properties the sandbox does **not** enforce. Operators -who care about any of these should plan their deployment accordingly. - -## Resource bounds - -### No reduction limit / hard CPU cap - -The wall-clock timeout is the only resource bound today. A script can -soak its full per-callback budget every tick without being throttled. -Luerl upstream does not currently expose a "reduction limit" or -"process-bound state" knob; a future hardening pass may add a soft -budget on the Luerl scheduler. - -### No per-script heap cap - -Lua tables grow inside the BEAM process heap. A pathological script -that allocates 100 MB of tables and drops them every tick will pressure -the OS memory allocator. The decode depth cap (64 levels) bounds -recursion at the bridge boundary, but does not bound table *size*. - -### Per-callback state copy cost is linear - -Each timeout-wrapped callback spawns a child process that takes a full -copy of the Luerl state (`spawn(fun() -> call(..., St) end)`). Cost is -linear in script-side allocation. A script that intentionally builds -large stable tables forces every later callback to pay the copy. Watch -for unexplained per-tick latency growth on long-lived matches. - -## Deployment hygiene - -### The container release tree is writable - -The shipped Dockerfile runs as the non-root `asobi` user but does not -declare `--read-only`. The README example mounts `/app/game` `:ro`; -that mode is the **operator's** responsibility, not the runtime's. We -recommend `docker run --read-only --tmpfs /tmp` and chowning only -`/app/game` to the runtime user (the rest of `/app` should stay -root-owned + read-only). - -### Symlinks under the game dir - -`require` rejects symlinks at resolve time, so a misplaced symlink -under `/foo.lua` no longer slips through. This is defense in -depth: keep the game dir mounted read-only and the build pipeline -should not produce symlinks in the first place. - -## Behavioural - -### Mid-callback rollback is best-effort - -If a callback is killed by its wall-clock timeout *after* it has -already issued a side-effecting `game.*` API call (e.g. -`game.economy.debit`), the side effect persists. The Lua-side state -reverts to the prior tick but the asobi-side ledger does not. Treat -economy / leaderboard / storage mutations as **best-effort committed**. -For high-stakes flows, checkpoint state before/after the API call so -the next tick reconciles, or wrap mutations in a transactional helper -tagged with the call's ref. - -### Bot `think/2` errors fall back to the built-in default AI - -A rate-limited `logger:warning` is emitted (one line per bot per -minute) when the fallback fires so persistently-broken scripts are -visible — see the `maybe_log_think_error` helper in `asobi_bot`. -Operators who rely on bot scripts should still monitor behaviour -externally; a silent fallback bot will keep playing the match without -ever calling your custom AI. - -## Logging - -### `require_failed` error payload is truncated - -When `luerl:do/2` rejects a `require`'d file (non-Lua content, -syntactically invalid Lua), the compiler error list is truncated to the -first three entries before propagating. This prevents a binary file -mistakenly placed under the game dir from dumping arbitrary bytes into -the structured log pipeline. diff --git a/guides/security-sandbox.md b/guides/security-sandbox.md deleted file mode 100644 index 2d08bd3..0000000 --- a/guides/security-sandbox.md +++ /dev/null @@ -1,99 +0,0 @@ -# Sandbox model - -asobi_lua runs every Lua script in a hardened Luerl state. Sandbox -construction lives in `asobi_lua_loader:new/1` and -`asobi_lua_loader:init_sandboxed/0`. - -## Removed from the global environment - -The following standard-library entries are cleared (`= nil`) so a hostile -script cannot reach them: - -- **OS escape hatches:** `os.execute`, `os.exit`, `os.getenv`, - `os.remove`, `os.rename`, `os.tmpname` -- **Code loading:** `dofile`, `loadfile`, `load`, `loadstring` -- **I/O:** the entire `io` library -- **Package machinery:** the entire `package` library, plus the default - `require` -- **Unstructured logging:** `print`, `eprint` — Luerl's defaults bypass - the structured logger and write straight to BEAM stdout. Use - `game.log(level, message[, meta])` instead: it routes a structured, - size-bounded line through the host logger behind a rate limit (per - match/zone plus a node-wide backstop), closing the two holes `print` - was removed for. See the Lua scripting guide's "Logging" section. - -`os.clock`, `os.date`, `os.difftime`, and `os.time` remain available so -games can timestamp. - -## Replaced - -- **`require/1`** is provided by asobi_lua. Names must match - `[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*` — letters, digits, - underscores, with `.` separating segments. Names like `../foo`, - `/etc/passwd`, `foo/bar`, `42`, or `''` are rejected. The validator - uses the `dollar_endonly` regex flag so `require("foo\n")` does not - slip through. The resolver joins the validated name to the directory - of the script that was loaded (e.g. `require("bots.chaser")` → - `/bots/chaser.lua`) and reads the file with `file:read_file/1`. - Symlinks at the resolved path are rejected before reading. Module - results are cached in the Luerl state's private `_ASOBI_LOADED` - table; `asobi_lua_match` clears that cache on hot-reload so changed - modules pick up. -- **`math.random`** dispatches to Erlang's `rand:uniform`. Single-arg - form returns an integer in `[1, N]`; no-arg form returns a float in - `[0, 1)`. The two-arg `math.random(a, b)` form upstream Lua exposes - is **not** supported. -- **`math.sqrt`** dispatches to Erlang's `math:sqrt/1`. Negative input - returns `0.0` (upstream Lua returns NaN; Erlang would crash). - -## Per-callback wall-clock limits - -Every Lua callback the bridges call (init, tick, join, leave, -get_state, vote_requested, vote_resolved, generate_world, -phases, spawn_templates, on_phase_started/ended, on_zone_loaded/unloaded, -on_world_recovered, terrain_provider, spawn_position, post_tick, -zone_tick, bot `think`) runs in a child process with a wall-clock -budget. A runaway script (`while true do end`, deep recursion, huge -allocation) is killed when its budget elapses; the parent gen_server -logs a warning and continues with the previous state. Limits are tuned -per callback — init/generate_world get more time, per-tick callbacks -get less. See the `?*_TIMEOUT` macros in `asobi_lua_match.erl` and -`asobi_lua_world.erl`. - -**`handle_input/3` is the exception: it is _not_ wall-clock-bounded.** It runs -inline for measured tail-latency wins at high input rates (ADR 0002), so a -`while true do end` there hangs the match until the gen_server timeout (5 s) and -the supervisor restarts the match — blast radius one match. It is not a sandbox -boundary; see the [trust model](security-trust-model.md#per-callback-isolation). - -The same wall-clock wrapper is applied to the **initial script body** -load (`asobi_lua_loader:new/1`), the **hot-reload** path (in -`asobi_lua_match`'s reload helper), and the **config manifest** -evaluator (in `asobi_lua_config`). A `while true do end` at the top -of `match.lua` therefore can no longer hang application start or the -match gen_server. - -## Cross-script isolation - -Each match and each zone gets its own Luerl state. Globals, modules, -and the require cache live inside that state — there is no shared -table reachable from script code that crosses match boundaries. - -## Atom exhaustion - -`asobi_lua_api`'s `safe_to_atom` helper and `terrain_provider` -decoding both use `binary_to_existing_atom/1` so a Lua-supplied string -cannot inflate the global atom table. Additionally, the terrain -provider module name is matched against an explicit allowlist -(`asobi_terrain_flat`, `asobi_terrain_perlin` by default; configurable -via the `asobi_lua, terrain_providers` env) so a script cannot -dispatch into arbitrary loaded modules even if the underlying atom -already exists. There is a regression test in -`asobi_lua_sandbox_tests` that fails if the limit is widened. - -## Decode depth cap - -`asobi_lua_api`'s deep-decode helper recurses on Lua-side tables; -depth is capped at 64 levels and over-deep subtrees are replaced with -the atom `too_deep`. A malicious script returning a 100k-deep table -from a callback can no longer blow the parent process heap. diff --git a/guides/security-trust-model.md b/guides/security-trust-model.md deleted file mode 100644 index d3d9089..0000000 --- a/guides/security-trust-model.md +++ /dev/null @@ -1,90 +0,0 @@ -# Trust model - -asobi_lua treats the mounted `/app/game` Lua scripts as **trusted** in -the same sense your `/app/bin/asobi_lua` binary is trusted: you control -what files end up there. The sandbox protects against incidental -scripting bugs (infinite loops, missed nil checks, atom exhaustion via -untrusted player input) and makes it harder for a *compromised* -dependency or `require`'d module to escape. It is not a defence against -a deliberate, all-Erlang-aware adversary with the ability to write -`/app/game/match.lua`. - -## Verified negative results - -These are properties prior security audits looked at and confirmed -hold. Documented here so future readers don't re-derive them. - -### `setmetatable(_G, ...)` and `setmetatable(os, ...)` are still allowed - -The strip pass calls `set_table_keys` with `nil`, which Luerl's -`set_table_key_key/4` *erases* the entry from the underlying ttdict — -the key becomes truly absent, not "set to nil". A subsequent `__index` -metatable on `os` (or `_G`) would intercept lookups for the absent -keys. However, `__index` can only return values that exist in the -script's reach, and the actual Erlang function references for -`os.execute`, `os.exit`, etc. are stored exclusively inside the os -table dict that was just erased. Once erased there is no Lua-reachable -path to those function references — they are not stored elsewhere in -the Luerl state. So metatable manipulation cannot recover stripped -functions. - -### `_ASOBI_LOADED` is reachable via `_G._ASOBI_LOADED` - -The require cache is installed as a global, fully visible to Lua. A -script can iterate it, mutate it, delete entries. There's no privilege -boundary inside a single Luerl state, so this is by design and -acceptable. Cross-match isolation comes from each match having its own -state; a script that clobbers its own cache only DoSes itself. The internal -`lookup_loaded` helper in `asobi_lua_loader` handles a clobbered -cache cleanly rather than crashing with `case_clause`. - -### Atom-table inflation via `terrain_provider` - -A Lua script that returns `{ module = "", ... }` from -`terrain_provider/1` cannot inflate the atom table — the bridge uses -`binary_to_existing_atom/1`. As of the F-* hardening pass the bridge -also requires the target module to be on an explicit allowlist -(`asobi_terrain_flat`, `asobi_terrain_perlin` by default; configurable -via `application:get_env(asobi_lua, terrain_providers, ...)`) so a -script that names an unrelated loaded module (`gen_server`, `rpc`, -etc.) is rejected with a `terrain_provider_not_allowed` warning. - -## Per-callback isolation - -Most Lua callbacks run inside a child process spawned by the loader's -`bounded_eval` wrapper with a wall-clock timeout and a -`max_heap_size: kill => true`. A runaway loop or a runaway allocation -in those callbacks crashes the child, the parent gen_server receives a -`{error, timeout | heap_exhausted}` result, and the match continues. - -| Callback | Bridge | Bounded? | Budget | -|---|---|---|---| -| `init/1` | match, world | yes | 1000-2000 ms | -| `tick/1`, `zone_tick/2` | match, world | yes | 500 ms | -| `get_state/{1,2}` | match, world | yes | 100 ms | -| `join/2`, `leave/2` | match, world | yes | 200 ms | -| `vote_*` | match | yes | 200 ms | -| `phases/1` | match, world | yes | 1000-2000 ms | -| `on_phase_*/2` | match, world | yes | 200 ms | -| `terrain_provider/1` | world | yes | 2000 ms | -| **`handle_input/3`** | **match, world** | **NO** | **(see below)** | - -`handle_input/3` is the one callback that does **not** spawn-isolate. -At realistic input rates (one tick × N players × the message rate) -the per-call spawn cost dominated the actual Lua work (~30-50 µs spawn -+ monitor + heap-cap setup vs ~50-200 µs of input handling). Removing -the wrapper recovered measured tail-latency wins of 35-45 % at 200 -players × 10 Hz input. See ADR 0002. - -The trade is explicit: a `while true do end` inside `handle_input` now -hangs the match server until its caller's `gen_server:call/2` timeout -trips (5 s default). The match supervisor then restarts the match -process. Blast radius is one match. - -`handle_input/3` is therefore **not a sandbox boundary**. It is a hot -path for trusted-author scripts. Audit the inputs your match script -accepts and avoid pattern-matching dispatch on attacker-controlled -strings; otherwise, treat the same as you would any Erlang gen_server -handle_call/2 implementation. Per-tick safety remains owned by -`tick/1`, which still spawn-isolates and is the right place to -enforce wall-clock fairness across players. diff --git a/guides/self-hosting.md b/guides/self-hosting.md deleted file mode 100644 index cfe2f97..0000000 --- a/guides/self-hosting.md +++ /dev/null @@ -1,241 +0,0 @@ -# Self-hosting asobi_lua - -This guide covers running asobi_lua in production on infrastructure you -control. It is opinionated about how Lua game scripts get onto disk and -when they reload, because that is the question every operator hits in -the first week. - -If you are evaluating asobi_lua locally, follow the -[quickstart](../README.md#quickstart) first — this guide assumes you -have something working and now want it deployed. - -## What ships in the container - -`ghcr.io/widgrensit/asobi_lua` is a Debian-trixie-slim runtime image -built on top of Erlang/OTP 28.x. It expects: - -- A Postgres 17+ database it can read and write (sessions, world - snapshots, leaderboards, IAP receipts). -- A directory mounted at `/app/game/` containing your Lua scripts. -- TCP `:8084` reachable by your matchmaker / game clients. - -That's it. No sidecars, no message bus, no Redis. The container is -stateless apart from `/app/game/`; restarting it loses no game state -beyond what was kept only in memory. - -## Where Lua scripts live - -`/app/game/` is the search path for `require()` and the source of every -Lua callback the runtime invokes (match handlers, world tick, bots). -The runtime calls `filelib:last_modified/1` on these files between game -ticks; if the mtime moves, it re-executes the script body against the -existing Luerl state. See `asobi_lua_reload` for the primitive. - -You have four ways to put scripts there in production. Pick the one -that matches how you ship code. - -### Pattern 1 — Bake into the image (immutable) - -**When:** you treat game-script changes as deploys. Each release of -your game is a new container image, rolled out via your existing -container orchestrator (Kubernetes, Nomad, Fly, plain `docker compose -up -d`). - -**How:** extend the asobi_lua image and `COPY` your scripts into -`/app/game/`: - -```Dockerfile -FROM ghcr.io/widgrensit/asobi_lua:latest -COPY game/ /app/game/ -``` - -Build, push to your registry, and deploy as you would any service. -mtime never changes inside a running container, so the per-tick -`stat()` cost is essentially free, but no live reload happens — -you ship code by shipping a container. - -This is the safest model and the one we recommend by default. If you -are not sure which pattern you want, start here. - -### Pattern 2 — Volume mount + atomic rename (live updates) - -**When:** you want to update scripts without rolling the runtime — -e.g. during a live event, or because your design team iterates on -balance numbers faster than your release train moves. - -**How:** mount a host directory (or a network volume your CI can -write to) at `/app/game/`: - -```yaml -services: - asobi_lua: - image: ghcr.io/widgrensit/asobi_lua:latest - volumes: - - /srv/asobi/game:/app/game:ro -``` - -When you ship new code, **always write the file under a temp name and -`mv` it into place**. POSIX `rename(2)` is atomic; an editor's "save" -that truncates and re-writes the file is not, and the runtime can -observe a half-written file and crash the load. - -```bash -# Wrong — runtime may stat() while the file is empty -cp build/match.lua /srv/asobi/game/match.lua - -# Right — atomic swap, runtime never sees a partial file -cp build/match.lua /srv/asobi/game/match.lua.tmp -mv /srv/asobi/game/match.lua.tmp /srv/asobi/game/match.lua -``` - -The next match/world tick picks up the new mtime and reloads. In-flight -match state survives the reload because the script body re-declares -globals and functions in place; existing locals and table fields are -not touched unless the script explicitly re-runs `init()`. - -For world zones, a reload that adds or changes `spawn_templates` also -takes effect immediately: an already-running zone re-fetches its spawn -template set on the tick right after the reload, so new templates -become spawnable without restarting the zone. If the reloaded -`spawn_templates` itself fails, the zone's existing templates are left -untouched rather than cleared. - -If a new script has a syntax error, the runtime keeps running the old -code, logs a warning, and remembers the new mtime so it does not retry -the same broken file every tick. Fix the file, save again, and the -next tick reloads. The same per-script rate limiting applies to any -callback that starts failing on every tick (not just syntax errors) — -the failure is logged at most a few times per window, not once per -tick forever. - -### Pattern 3 — Signal-driven reload (planned) - -A future `ASOBI_LUA_RELOAD=signal` mode and admin RPC will let you skip -the per-tick `stat()` entirely and reload only when explicitly -triggered (e.g. by your CI/CD pipeline, after the file is in place). -This is the right model for very-high-zone-count deployments where the -mtime-poll overhead becomes measurable. Tracked in -[#TBD]; until it ships, use pattern 2. - -### Pattern 4 — Custom script source (planned) - -If your scripts live somewhere other than a filesystem — Postgres, S3, -git tags, a CMS — you will eventually want the planned -`asobi_lua_source` behaviour, which dispatches to either the -filesystem implementation or a custom loader. Until that lands, the -practical workaround is a small sidecar that pulls from your source -and writes to `/app/game/` using pattern 2. Tracked in [#TBD]. - -## A minimal production compose - -```yaml -services: - postgres: - image: postgres:17 - environment: - POSTGRES_USER: asobi - POSTGRES_PASSWORD_FILE: /run/secrets/db_password - POSTGRES_DB: asobi - volumes: - - pgdata:/var/lib/postgresql/data - secrets: [db_password] - healthcheck: - test: ["CMD-SHELL", "pg_isready -U asobi"] - interval: 5s - timeout: 5s - retries: 5 - restart: unless-stopped - - asobi_lua: - image: ghcr.io/widgrensit/asobi_lua:latest - depends_on: - postgres: - condition: service_healthy - environment: - ASOBI_DB_HOST: postgres - ASOBI_DB_NAME: asobi - ASOBI_DB_USER: asobi - ASOBI_DB_PASSWORD_FILE: /run/secrets/db_password - ASOBI_NODE_HOST: 0.0.0.0 - volumes: - - /srv/asobi/game:/app/game:ro - secrets: [db_password] - ports: - - "8084:8084" - restart: unless-stopped - -secrets: - db_password: - file: ./db_password.txt - -volumes: - pgdata: -``` - -Put this behind a TLS-terminating reverse proxy (Caddy, nginx, -Traefik) — asobi_lua speaks plain HTTP/WebSocket and expects the proxy -to handle certificates. - -## Tuning knobs - -These are read at start time from your `sys.config`. - -| Key | Default | What it does | -|---|---|---| -| `asobi_lua.max_heap_words` | `5_000_000` | Per-eval heap cap (in Erlang words) for every Lua callback the runtime invokes. If a single eval allocates past this, the eval process is killed by the VM and the runtime returns `{error, heap_exhausted}`. Persistent state held by the gen_server is not touched — only the runaway eval. Raise only if a single tick legitimately constructs a very large local structure; long-lived tables belong in the persistent Luerl state and cost nothing per eval. | -| `asobi_lua.reload_mode` (or env `ASOBI_LUA_RELOAD`) | `auto` | `auto` mtime-polls the script on every tick. `off` skips the poll entirely — appropriate for sealed-bundle prod where new code is a container restart, not a file change. Anything we don't recognise falls back to `auto` so a typo doesn't silently disable reload. | - -```erlang -%% sys.config -[ - {asobi_lua, [ - {max_heap_words, 10_000_000}, - {reload_mode, off} - ]} -]. -``` - -Or, in a Docker deploy, just set `ASOBI_LUA_RELOAD=off` in the container env. - -## Validating Lua scripts in CI - -Before deploying a new `match.lua` or `world.lua`, run it through the -loader in CI to catch syntax errors and sandbox violations without -booting a full runtime: - -```bash -docker run --rm -v "$PWD/lua:/g" ghcr.io/widgrensit/asobi_lua \ - bin/asobi_lua eval 'asobi_lua_validate:cli(["/g/match.lua"]).' -``` - -Exits 0 on a clean script, 1 with the loader's error reason on stderr -otherwise. Pass multiple paths to validate them sequentially; the run -exits on the first failure. - -## Operating notes - -- **Database backups.** Postgres holds session tokens, world - snapshots, and leaderboards. Use `pg_dump` or `pg_basebackup` on - whatever cadence your data loss tolerance requires; nothing in - asobi_lua is recoverable from the runtime alone. -- **Logs.** asobi_lua emits structured JSON via `nova_jsonlogger`. - Ingest them as JSON lines from container stdout. -- **Crash dumps.** Erlang writes `erl_crash.dump` to the working - directory on a VM crash. In a container that means it is lost on - restart unless you mount a writable volume — we recommend leaving - it ephemeral; if you need post-mortem capability, mount a - short-retention volume at `/app`. -- **Restarts are cheap.** The container takes single-digit seconds to - boot. In-flight matches are not preserved across restarts (they - rely on in-memory state); design clients to reconnect. - -## What this guide does not cover - -- Multi-tenant hosting. The asobi managed cloud (and the private - `asobi_engine` image behind it) handles tenant isolation; the public - `asobi_lua` image is single-tenant. -- Horizontal scaling beyond one node. Asobi clusters via standard - Erlang distribution; multi-node ops is its own topic and lives in - the `asobi` library docs. -- Stripe / IAP / payments. Those are part of the managed cloud; the - open-source runtime ships only the IAP-receipt-validation primitive.