Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/docs-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions guides/datagram-plane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions guides/lobbies.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion guides/lua-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion guides/matchmaking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 2 additions & 2 deletions guides/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion guides/phases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion guides/rest-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
18 changes: 9 additions & 9 deletions guides/websocket-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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"}}}}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -773,13 +773,13 @@ 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`

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.
Expand Down Expand Up @@ -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).
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions guides/world-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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 |
Expand Down
114 changes: 114 additions & 0 deletions scripts/check-doc-anchors.sh
Original file line number Diff line number Diff line change
@@ -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
Loading