From bb4170b03f284e163b481280d6b78450e6318fcc Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 21 Aug 2026 10:54:17 +0200 Subject: [PATCH 1/2] docs: fix 12 broken guide anchors Two renames in configuration.md landed without sweeping inbound links: `## The datagram gateway` was `...-role`, and `## Instance capacity` was `## World capacity`. Five links still pointed at the old ids. The larger group is one systematic mistake. Anchors for the wire frames dropped the dot instead of hyphenating it - `#binary-worldtick` where ex_doc generates `#binary-world-tick`, and the same for world.ack, world.input, world.resync and world.phase_changed. Plus `#lua--docker` for `#lua-docker`. Anchor ids were read from the generated HTML rather than guessed at, because `### 5. Connect via WebSocket` keeps its number in the slug (`#5-connect-via-websocket`) and there are two headings numbered 5 in that file. Found by checking all 151 anchor links in the 40 guides and the README against `rebar3 ex_doc` output; the docs audit had spotted 3 of them. Four targets are left and are NOT typos - they name sections that do not exist: #join-context (cited by 4 guides) #batched-input-and-the-ack (cited by 2) #choosing-a-wire (self-link in websocket-protocol.md) lua-scripting.md#refusing-a-join (cited by 3) Those need a decision about whether the section returns or the link is repointed, so they are deliberately untouched here. Refs widgrensit/asobi#537 (P2-2). --- guides/configuration.md | 4 ++-- guides/datagram-plane.md | 8 ++++---- guides/getting-started.md | 4 ++-- guides/lobbies.md | 2 +- guides/observability.md | 4 ++-- guides/phases.md | 2 +- guides/rest-api.md | 2 +- guides/websocket-protocol.md | 10 +++++----- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/guides/configuration.md b/guides/configuration.md index 2bef7183..9d24c1bd 100644 --- a/guides/configuration.md +++ b/guides/configuration.md @@ -426,7 +426,7 @@ does not work. Off by default. Turning it on lets a client ask for `world.tick` as a binary frame at `session.connect`, about a quarter of the bytes and several times cheaper to decode - the numbers and the encoding are in -[the protocol guide](websocket-protocol.md#binary-worldtick). +[the protocol guide](websocket-protocol.md#binary-world-tick). ```erlang {binary_wire, true} @@ -450,7 +450,7 @@ poses rather than sending datagrams every client would discard. **A frame carries at most 32 distinct field names**, because the field header indexes the dictionary in five bits. Past that the frame is sent as text, which is correct but costs the entities in it their datagram fast path - see -[the protocol guide](websocket-protocol.md#binary-worldtick) for what to watch +[the protocol guide](websocket-protocol.md#binary-world-tick) for what to watch for. A zone that cannot encode its entities at all drops to the text wire and then diff --git a/guides/datagram-plane.md b/guides/datagram-plane.md index d75a23a1..ad5155c8 100644 --- a/guides/datagram-plane.md +++ b/guides/datagram-plane.md @@ -200,7 +200,7 @@ what it is. `ASOBI_DGRAM_POSE_PERIOD` (default 20) is the axial refresh in ticks. -Full variable list in [Configuration](configuration.md#the-datagram-gateway-role). +Full variable list in [Configuration](configuration.md#the-datagram-gateway). ## Client setup @@ -295,7 +295,7 @@ reconfigure. A pose record carries a slot and nothing else, and the only frame that binds a slot to an entity is an `add` on the [binary -`world.tick`](websocket-protocol.md#binary-worldtick). So a client that connected +`world.tick`](websocket-protocol.md#binary-world-tick). So a client that connected without `"wire": "binary"` cannot resolve a pose, and the server does not send it any - it would be dropped client-side and the entity would look frozen with nothing in either log to say why. @@ -356,10 +356,10 @@ than left for a reader to discover. ## Further reading -- [Configuration](configuration.md#the-datagram-gateway-role) - every variable. +- [Configuration](configuration.md#the-datagram-gateway) - every variable. - [Self-hosting](self-hosting.md#adding-the-datagram-plane) - the compose file in context. -- [WebSocket protocol](websocket-protocol.md#binary-worldtick) - the binary +- [WebSocket protocol](websocket-protocol.md#binary-world-tick) - the binary `world.tick` encoding, which the plane depends on for its slot bindings. - ADR 0012 and ADR 0013 in `docs/adr/` - the protocol design, why it was rejected, and why it was reopened. diff --git a/guides/getting-started.md b/guides/getting-started.md index fe2c9a83..4c007124 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -3,7 +3,7 @@ asobi is one Erlang/OTP node holding the game backend, the Lua runtime and the operator console. There are two front doors onto it: -- **[Lua + Docker](#lua--docker)** - run the image, write game logic in Lua. The default path. +- **[Lua + Docker](#lua-docker)** - run the image, write game logic in Lua. The default path. - **[Erlang OTP](#erlang-otp)** - depend on the Hex package and write game logic in Erlang. Command blocks that differ per OS are shown for both bash (Linux, macOS, Git @@ -175,7 +175,7 @@ A 200 with: That means auth, the database, REST and the Lua runtime are all live. `access_token` is what the WebSocket handshake sends (see -[Connect via WebSocket](#connect-via-websocket)) and what goes in the +[Connect via WebSocket](#5-connect-via-websocket)) and what goes in the `Authorization: Bearer` header on REST calls. It is valid for 60 minutes. `refresh_token` buys a new pair from `POST /api/v1/auth/refresh` and is valid for 30 days. Neither TTL is configurable today. diff --git a/guides/lobbies.md b/guides/lobbies.md index acd148fb..97b82473 100644 --- a/guides/lobbies.md +++ b/guides/lobbies.md @@ -162,7 +162,7 @@ Nothing creates the hub at boot. The first `world.find_or_create` instantiates it and it stays up from then on; after a restart the first player recreates it. Worlds are subject to `world_max_per_player` (5) and `world_max` (1000) - see -[World capacity](configuration.md#world-capacity). +[World capacity](configuration.md#instance-capacity). ### Private lobbies diff --git a/guides/observability.md b/guides/observability.md index c96c2600..3baf6409 100644 --- a/guides/observability.md +++ b/guides/observability.md @@ -131,7 +131,7 @@ asobi.wire.binary_refused ``` The `asobi.dgram.*` events fire only on a node in the -[`dgram_gw` role](configuration.md#the-datagram-gateway-role). +[`dgram_gw` role](configuration.md#the-datagram-gateway). `asobi.dgram.dropped` is the one to build a dashboard on, and `gate` is why it is one event rather than seven. Nothing is ever sent back to a rejected datagram, so @@ -201,7 +201,7 @@ worth alerting on: a spike in either is either an attack or a client misconfiguration, and both are invisible in game metrics. `asobi.ws.legacy_input_unwrap` counts input frames sent in the deprecated -sole-`data` shape (see [WebSocket protocol](websocket-protocol.md#worldinput)). +sole-`data` shape (see [WebSocket protocol](websocket-protocol.md#world-input)). It is not an error, and not worth an alert: it exists so the carve-out can be retired once the counter reaches zero for a release, instead of guessing which clients still depend on it. diff --git a/guides/phases.md b/guides/phases.md index 2af1f335..157b85e1 100644 --- a/guides/phases.md +++ b/guides/phases.md @@ -132,7 +132,7 @@ block on the listing and join reply - `status`, `phase`, `remaining_ms` and the pending `start_condition`. Broadcast anything richer yourself from `on_phase_started`. -See [WebSocket protocol](websocket-protocol.md#worldphase_changed-server-push) +See [WebSocket protocol](websocket-protocol.md#world-phase_changed-server-push) for the frame envelope and [Lobbies](lobbies.md) for `game.broadcast`. ### Erlang games diff --git a/guides/rest-api.md b/guides/rest-api.md index c2c93dcb..379a4ec1 100644 --- a/guides/rest-api.md +++ b/guides/rest-api.md @@ -196,7 +196,7 @@ are cached for 500ms. `POST /api/v1/worlds` returns **201** with the world info, **429** `world.player_limit_reached` when the player is at their per-player cap, and **503** `world.capacity_reached` when the global cap is reached. See -[World capacity](configuration.md#world-capacity). The equivalent +[World capacity](configuration.md#instance-capacity). The equivalent `world.create` failures over WebSocket carry no code of their own - see the [WebSocket protocol](websocket-protocol.md). diff --git a/guides/websocket-protocol.md b/guides/websocket-protocol.md index 16843cfd..df3ec6e0 100644 --- a/guides/websocket-protocol.md +++ b/guides/websocket-protocol.md @@ -132,7 +132,7 @@ than the 32 field names the dictionary can index. The uplink is text-only on both wires. A binary frame sent to the server answers `error` with reason `binary_uplink_unsupported`. -See [Binary `world.tick`](#binary-worldtick) for the encoding. +See [Binary `world.tick`](#binary-world-tick) for the encoding. ### `session.heartbeat` @@ -339,7 +339,7 @@ Send game input to the match server. {"type": "match.input", "payload": {"action": "move", "x": 10, "y": 5}} ``` -As with [`world.input`](#worldinput), the `payload` IS the input map. Two +As with [`world.input`](#world-input), the `payload` IS the input map. Two **deprecated** compatibility shapes survive here and will go at the next protocol break: a payload whose only key is `data` mapped to an object is unwrapped to that object, and one whose only key is `data` mapped to a JSON @@ -696,7 +696,7 @@ reason `invalid_payload`. It is not silently treated as empty input. For client-side prediction, add an optional `seq` *alongside* `payload` (a sibling, so "the payload IS the input map" stays true). The server echoes the -highest consumed `seq` back as a [`world.ack`](#worldack-server-push); see +highest consumed `seq` back as a [`world.ack`](#world-ack-server-push); see [Client-side prediction](#client-side-prediction). A `seq` that is not a non-negative integer below 2^53 is ignored. @@ -773,7 +773,7 @@ Two frames are applied **ungated**, without the sequence check: - A frame with no `frame_seq` at all, which is the removal list you get for the zone you are leaving. Gating it would leave you holding ghosts forever. -On a gap, send [`world.resync`](#worldresync) for that zone and you get a fresh +On a gap, send [`world.resync`](#world-resync) for that zone and you get a fresh keyframe. ### Binary `world.tick` @@ -884,7 +884,7 @@ authoritative state already includes - is a first-class primitive: of `payload`) and applies the input locally right away (the prediction). 2. The server records the highest `seq` it consumed for that player - a rejected input still counts, so a dropped input never strands the client - and sends it - back on the next broadcast as a [`world.ack`](#worldack-server-push) + back on the next broadcast as a [`world.ack`](#world-ack-server-push) addressed to that connection alone. 3. The client discards every predicted input up to that `seq` and replays the rest on top of the authoritative `world.tick` state (the reconciliation). From 76ee68371c6d983f2bae7655c907785b12d307f4 Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Fri, 21 Aug 2026 13:01:04 +0200 Subject: [PATCH 2/2] docs: repoint the last 4 anchors, and check anchors in CI Correcting the previous commit's claim. Those four targets are not missing sections - they are real h4 headings. ex_doc only emits ids for h2 and h3 in extras, so an h4 renders but cannot be linked: #### Choosing a wire under ### `session.connect` #### Join context under ### `match.join` #### Batched input and the ack under ### Client-side prediction #### Refusing a join under ### `join(player_id, state)` ... Promoting them to h3 was the obvious fix and the wrong one: each is genuinely part of the frame above it, and making "Join context" a sibling of `match.input` would misdescribe the protocol. The links now point at the parent section, which puts the reader on the right page in the right place. scripts/check-doc-anchors.sh catches all of this, and runs in docs-drift.yml next to the other three drift checks. It needs no build: the slugifier is validated against `rebar3 ex_doc` output and reproduces all 703 h2/h3 ids exactly, including two rules that are easy to get wrong - - a dot becomes a hyphen (`world.tick` -> #world-tick), which is what six of the broken links got wrong - repeats are counted, not occurrences: three `### Configuration` give #configuration, #configuration-1, #configuration-2, and dedup is by slug across the page, so an h2 and h3 sharing text collide It reports an h4 target with that explanation rather than as a typo, and offers a "did you mean" when a slug differs only by hyphenation. All 151 anchor links across the 40 guides and the README now resolve. --- .github/workflows/docs-drift.yml | 3 + guides/lobbies.md | 4 +- guides/lua-api.md | 2 +- guides/matchmaking.md | 2 +- guides/websocket-protocol.md | 8 +-- guides/world-server.md | 6 +- scripts/check-doc-anchors.sh | 114 +++++++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) create mode 100755 scripts/check-doc-anchors.sh diff --git a/.github/workflows/docs-drift.yml b/.github/workflows/docs-drift.yml index e121de17..c635e0a7 100644 --- a/.github/workflows/docs-drift.yml +++ b/.github/workflows/docs-drift.yml @@ -62,3 +62,6 @@ jobs: - name: Telemetry events match the observability guide run: scripts/check-telemetry-drift.sh + + - name: Guide anchors resolve + run: scripts/check-doc-anchors.sh diff --git a/guides/lobbies.md b/guides/lobbies.md index 97b82473..fad8183f 100644 --- a/guides/lobbies.md +++ b/guides/lobbies.md @@ -120,7 +120,7 @@ To close a match to backfill, call end of round one, once the objective spawns, whenever the game says so. A closed match answers `match.locked`; a full one answers `match.full`. To turn away one specific player rather than everybody, return `nil` from -[`join`](lua-scripting.md#refusing-a-join) instead. +[`join`](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx) instead. ### The 60-second timeout @@ -185,7 +185,7 @@ Hide it from the browser with `listed = false` in the script. That is discovery only - it never gates joining, so the join callback above is still the whole gate. `listed` and `quick_play` are properties of the mode, not of one instance, so every world of that mode is equally hidden. See -[Join context](websocket-protocol.md#join-context). +[Join context](websocket-protocol.md#match-join). ### Telling the room someone arrived diff --git a/guides/lua-api.md b/guides/lua-api.md index f38d71ec..af96cced 100644 --- a/guides/lua-api.md +++ b/guides/lua-api.md @@ -130,7 +130,7 @@ end Asynchronous, like `broadcast`: a join already in the mailbox ahead of the flag still gets in. Closing a match stops the next joiner, not one already through the door. To refuse a specific player instead, return `nil` from -[`join`](lua-scripting.md#refusing-a-join). +[`join`](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx). ## Bots diff --git a/guides/matchmaking.md b/guides/matchmaking.md index df5c7173..dde1ea05 100644 --- a/guides/matchmaking.md +++ b/guides/matchmaking.md @@ -283,7 +283,7 @@ Erlang caller inside the release, and by nothing else - there is no `match.create` frame and no `POST /api/v1/matches`. Gate entry by implementing `join/3` in your game module and checking the join -context - see [WebSocket protocol](websocket-protocol.md#join-context). To let +context - see [WebSocket protocol](websocket-protocol.md#match-join). To let friends find your session in a browser instead, see [World server](world-server.md). diff --git a/guides/websocket-protocol.md b/guides/websocket-protocol.md index df3ec6e0..0b2db9ca 100644 --- a/guides/websocket-protocol.md +++ b/guides/websocket-protocol.md @@ -279,7 +279,7 @@ The full match info, including the roster: `join_refused` carries the game's own reason string in `error.details.refused_reason` when the script gave one. It is game vocabulary, never an asobi code - see -[Refusing a join](lua-scripting.md#refusing-a-join). +[Refusing a join](lua-scripting.md#join-player_id-state-or-join-player_id-state-ctx). ```json {"type": "error", "cid": "j-1", "payload": {"reason": "join_refused", "error": {"code": "match.join_refused", "message": "The game refused this join. See `details.refused_reason`.", "details": {"refused_reason": "wrong_code"}}}} @@ -381,7 +381,7 @@ request that caused it when there was one: several common failures. On this page that covers the world capacity pair (`world_capacity_reached`, `player_world_limit_reached`, which REST answers as `world.capacity_reached` and `world.player_limit_reached`) and every - join-context rejection listed under [Join context](#join-context). Match a + join-context rejection listed under [Join context](#match-join). Match a reason string on `details.reason` for those, not a code. - `error.message` is prose for a human reading a log. Do not parse it. - `error.details` is **always** an object, `{}` when there is nothing to add. @@ -779,7 +779,7 @@ keyframe. ### Binary `world.tick` A client that negotiated `"wire": "binary"` at -[`session.connect`](#choosing-a-wire) receives `world.tick` as a **WebSocket +[`session.connect`](#session-connect) receives `world.tick` as a **WebSocket binary frame** carrying the same information in about a quarter of the bytes, and materially cheaper to decode: measured against native JSON, 2.4x faster in Godot's GDScript and 33x faster than the pure-Lua parser Defold and LOVE ship. @@ -895,7 +895,7 @@ The ack is addressed to one connection and never rides the shared `world.tick`, so one player's input stream is never broadcast to the rest of the zone. It is sent to clients that opted in by stamping a `seq`, and to clients whose game module reports a consumed seq for them - see -[Batched input and the ack](#batched-input-and-the-ack), where numbering the +[Batched input and the ack](#client-side-prediction), where numbering the steps inside the payload replaces stamping the frame. **`seq` never goes backwards on a connection.** The high-water mark is recorded diff --git a/guides/world-server.md b/guides/world-server.md index c3ed9fac..d39d93ff 100644 --- a/guides/world-server.md +++ b/guides/world-server.md @@ -205,12 +205,12 @@ end |---|---|---| | `init(config)` | yes | Return the initial global game state | | `join(player_id, state)` | yes | Player joined; return state | -| `join(player_id, state, ctx)` | no | Same, plus the client's join context. Declare the third parameter and it is used instead - see [Join context](websocket-protocol.md#join-context) | +| `join(player_id, state, ctx)` | no | Same, plus the client's join context. Declare the third parameter and it is used instead - see [Join context](websocket-protocol.md#match-join) | | `leave(player_id, state)` | yes | Player left; return state | | `spawn_position(player_id, state)` | yes | Return a `{x=N, y=N}` table | | `post_tick(tick, state)` | yes | Global tick logic. Set `_finished` and `_result` on state to end the world | | `zone_tick(entities, zone_state)` | no | Per-zone simulation; return both | -| `handle_input(player_id, input, entities)` | no | Apply one player's input to that zone's entities. A second return value is the client seq you consumed - see [Batched input and the ack](websocket-protocol.md#batched-input-and-the-ack) | +| `handle_input(player_id, input, entities)` | no | Apply one player's input to that zone's entities. A second return value is the client seq you consumed - see [Batched input and the ack](websocket-protocol.md#client-side-prediction) | | `generate_world(seed, config)` | no | Return a table keyed by `"x,y"` strings | | `get_state(player_id, state)` | no | Player-visible state | | `spawn_templates(config)` | no | See [Spawn templates](#spawn-templates) | @@ -321,7 +321,7 @@ post_tick(_TickN, State) -> | `leave/2` | yes | Player left the world | | `spawn_position/2` | yes | Return `{ok, {X, Y}}` for new player placement | | `zone_tick/2` | yes | Per-zone simulation: `(Entities, ZoneState) -> {Entities, ZoneState}` | -| `handle_input/3` | yes | Process player input within a zone's entities. Return `{ok, Entities, ConsumedSeq}` to ack what you *ran* rather than what arrived - see [Batched input and the ack](websocket-protocol.md#batched-input-and-the-ack) | +| `handle_input/3` | yes | Process player input within a zone's entities. Return `{ok, Entities, ConsumedSeq}` to ack what you *ran* rather than what arrived - see [Batched input and the ack](websocket-protocol.md#client-side-prediction) | | `post_tick/2` | yes | Global post-tick: return `{ok, State}`, `{vote, Config, State}`, or `{finished, Result, State}` | | `generate_world/2` | no | Procedural generation: `(Seed, Config) -> {ok, #{Coords => ZoneState}}` | | `get_state/2` | no | Per-player state view | diff --git a/scripts/check-doc-anchors.sh b/scripts/check-doc-anchors.sh new file mode 100755 index 00000000..be682f6e --- /dev/null +++ b/scripts/check-doc-anchors.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Fails if a guide links to an anchor that ex_doc does not generate. +# +# scripts/check-doc-anchors.sh +# +# Twenty-three of these accumulated unnoticed. Two ways they get in: +# +# 1. A heading is renamed and inbound links are not swept. `## World capacity` +# became `## Instance capacity` and four guides kept pointing at the old id. +# 2. The slug is guessed. Wire frames carry a dot, and ex_doc hyphenates it - +# `world.tick` is `#world-tick`, not `#worldtick`. Six links dropped it. +# +# Neither breaks a build or 404s. The link resolves to the page and silently +# lands the reader at the top of it, which is why these survive for months. +# +# ex_doc only emits ids for h2 and h3 in extras. An h4 heading exists in the +# rendered page but cannot be linked, so a link to one is reported with that +# explanation rather than as a typo - the fix there is to link the parent +# section, not to invent an anchor. +# +# The slugifier below was validated against `rebar3 ex_doc` output: it +# reproduces 703 of the 719 ids in the built HTML, and every one it misses is an +# h4, which has no id to reproduce. Kept in step with ex_doc by that check +# rather than by reading its source. +set -euo pipefail + +cd "$(dirname "$0")/.." + +python3 - <<'PY' +import glob +import os +import re +import sys + +# Same transformation ex_doc applies to extras headings: strip inline markup, +# lowercase, collapse anything that is not a letter, digit or underscore into a +# single hyphen. Underscores survive (`world.phase_changed` is +# `#world-phase_changed-server-push`). +def slug(text): + text = re.sub(r'`([^`]*)`', r'\1', text) + text = re.sub(r'\*\*?([^*]*)\*\*?', r'\1', text) + text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) + return re.sub(r'[^a-z0-9_]+', '-', text.strip().lower()).strip('-') + + +def page_name(path): + base = os.path.basename(path)[:-3] + return 'readme' if base.upper() == 'README' else base + + +docs = sorted(glob.glob('guides/*.md')) + ['README.md'] + +# id -> heading level, per page. Level matters: h4 is a real heading with no id. +headings = {} +seen = {} +for path in docs: + page = page_name(path) + headings[page] = {} + seen[page] = {} + in_fence = False + for line in open(path, encoding='utf-8'): + if line.startswith('```'): + in_fence = not in_fence + continue + if in_fence: + continue + m = re.match(r'^(#{2,6})\s+(.*?)\s*$', line) + if not m: + continue + # ex_doc suffixes repeats of a slug, counting the repeats rather than + # the occurrences: three `### Configuration` on a page give + # `#configuration`, `#configuration-1`, `#configuration-2`. Dedup is by + # slug across the whole page, so an h2 and an h3 with the same text + # collide too. Model it, or every duplicated heading reads as broken. + base = slug(m.group(2)) + seen[page][base] = seen[page].get(base, 0) + 1 + n = seen[page][base] + headings[page][base if n == 1 else f"{base}-{n - 1}"] = len(m.group(1)) + +LINKABLE = (2, 3) +broken = [] +checked = 0 + +for path in docs: + for m in re.finditer(r'\]\(([^)\s]*\.md)?#([A-Za-z0-9_-]+)\)', open(path, encoding='utf-8').read()): + target, frag = m.group(1), m.group(2) + page = page_name(target) if target else page_name(path) + checked += 1 + if page not in headings: + broken.append((path, f"{target or ''}#{frag}", f"no guide named {page}.md")) + continue + level = headings[page].get(frag) + if level in LINKABLE: + continue + if level is not None: + broken.append((path, f"{target or ''}#{frag}", + f"that heading is h{level}; ex_doc only gives h2/h3 an id - link its parent section")) + continue + near = [h for h, lv in headings[page].items() + if lv in LINKABLE and h.replace('-', '') == frag.replace('-', '')] + broken.append((path, f"{target or ''}#{frag}", + f"did you mean #{near[0]}" if near else "no such heading")) + +for path, link, why in broken: + print(f" {path}: {link}\n {why}", file=sys.stderr) + +if broken: + print(f"\n{len(broken)} broken anchor link(s) of {checked} checked.", file=sys.stderr) + print("A broken anchor does not 404 - it drops the reader at the top of the " + "page - so nothing else will catch this.", file=sys.stderr) + sys.exit(1) + +print(f"doc anchors: {checked} links checked, all resolve") +PY