From 128511f219bff855a2908f7d94ee1c1fadceeb56 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 21 Aug 2026 09:01:26 +0200 Subject: [PATCH] docs: tell the truth about the image, and drop the stale guides The retirement banner said `ghcr.io/widgrensit/asobi_lua` "keeps being published from this repository". The commit that wrote it (03aac64) deleted docker-publish.yml in the same change, and entrypoint.sh has been printing "THIS IMAGE IS NO LONGER REBUILT" on every start ever since. A self-hoster who believed the banner would sit on a frozen image built from asobi v0.71.0, receiving no fixes including security fixes, while asobi ships v0.94.0. That is the worst thing this repository could say, and archiving it would have frozen it in place. README now leads with the rename, names ghcr.io/widgrensit/asobi, and says plainly that the old name is not rebuilt. It borrows entrypoint.sh's wording, which has been correct all along. 265 lines -> 52: the "kept for reference" section described a pre-merge layout that no longer exists, and a disclaimer part-way down does not survive someone landing mid-file from a search engine. Also fixes the logo (docs/media/logo.png, not docs/logo.png), the SECURITY.md anchor that never existed, and the contradiction between "this tracker is closed" and a link inviting bug reports here. guides/ deleted - all six were older, shorter forks of asobi's, and each still taught the retired image. security-sandbox.md had dropped the require/math contract and the per-callback budget sections entirely. docs/adr/ is KEPT, against the audit's advice: 0001 and 0002 exist nowhere else and are the reasoning behind decisions asobi still lives with. Archiving makes them read-only, which is the right home for them. AGENTS.md carried the same false alias claim - an agent reading it would repeat the error - and the CI comment claimed the image could not be rebuilt if the job went red, which is now moot. Findings from widgrensit/asobi#537 (P0-1, P0-6, P2-5, P2-6). --- .github/workflows/ci.yml | 3 +- AGENTS.md | 12 +- README.md | 275 ++---------- guides/lua-bots.md | 219 --------- guides/lua-scripting.md | 634 --------------------------- guides/security-known-limitations.md | 81 ---- guides/security-sandbox.md | 99 ----- guides/security-trust-model.md | 90 ---- guides/self-hosting.md | 241 ---------- 9 files changed, 41 insertions(+), 1613 deletions(-) delete mode 100644 guides/lua-bots.md delete mode 100644 guides/lua-scripting.md delete mode 100644 guides/security-known-limitations.md delete mode 100644 guides/security-sandbox.md delete mode 100644 guides/security-trust-model.md delete mode 100644 guides/self-hosting.md 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.