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 @@
+
+
+
+
# 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_lua
-
-
- Open-source game backend. Write it in Lua. Hot-reload without restart. Apache-2.
-
-
-
-
-
-
-
-
-
-
- Docs •
- Live demo •
- Discord •
- Issues
-
-
-
-
-
- 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.