docs: merge duplicate networking namespace dirs; guard against recurrence

Documentation namespace cleanup (issue #38).

Audit outcome:

- `date` vs `datetime` are NOT duplicates — `Date` is calendar days since the
  epoch, `DateTime` is instants (seconds); distinct runtime namespaces. Kept
  both.
- `network` vs `networking` WAS a real duplicate. Every other stdlib area
  documents only its namespace (`World.*`, `Screen.*`, …), never the bare
  builtins it lowers to. Networking alone also documented the low-level
  `net_*`/builtin forms under `networking/`, duplicating the `Network.*`
  pages under `network/`. Removed `networking/`; `network/` (the `Network`
  namespace, which the compiler and LSP both expose) is canonical. Folded the
  `@Sync`/`@Owned` framing into `network/_section.md` so no context is lost.
- Dropped the `networking` key from docgen inventory.json.

Guard (AC3): `tools/docgen/check.py` now fails if any `ns:` is documented from
more than one directory, or if two sections share an id or (case-folded)
title — so a duplicate-namespace split cannot silently reappear.

`gen.py` + `check.py` pass (34 sections, 365 symbols).

Closes #38

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 16:46:21 +03:00
parent 1c9235a948
commit 3bab2d2d4c
13 changed files with 34 additions and 312 deletions

View file

@ -4,4 +4,4 @@ title: Network
order: 6
---
The low-level networking seam — send and poll datagrams, serialize and apply entity state, and read ownership and role. Offline these collapse to single-player defaults.
The low-level networking seam — send and poll datagrams, serialize and apply entity state, and read ownership and role. Multiplayer is normally a compile-time property of the ECS, not glue you thread by hand: mark fields <code>@Sync</code> and models <code>@Owned</code> and the compiler generates the replication. These <code>Network.*</code> primitives are what that sugar lowers to, for when you drive the transport yourself. Offline they collapse to single-player defaults.

View file

@ -1,7 +0,0 @@
---
id: networking
title: Networking
order: 9
---
Multiplayer is a compile-time property of the ECS, not glue you thread by hand. Mark fields <code>@Sync</code> and models <code>@Owned</code>; the compiler generates the replication. These are the low-level primitives beneath that sugar, for when you drive the transport yourself.

View file

@ -1,36 +0,0 @@
---
id: fn-apply
name: apply
category: networking
kind: builtin
tokens: apply
sig: apply(target, buf, len)
tip: Fold received @Sync bytes back onto a model instance.
order: 50
---
<code>apply</code> is the exact inverse of <code>serialize</code>: it reads <code>len</code> bytes from <code>buf</code> and copies each value back into the replicable-and-participating fields of the target model instance, in the same member-then-field order the serializer packed them. It is compiler-generated and dispatches on the instance's model, so it only touches synced fields — an unsynced field keeps whatever value it already held. Use it after <code>net_poll</code> to reconcile a client to the authority's state, or after a rollback to restore a snapshot you took with <code>serialize</code>. The buffer must have come from a serializer for a matching model, or the layout will not line up.
Parameters:
- `target` — the model instance (an `entity`) to write the fields onto
- `buf` — the source buffer holding serialized bytes
- `len` — the number of bytes in `buf` to read
```ludic
program ReconcilePlayer {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 3, row: 4 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
let snapshot_buffer = bytes(64)
let byte_count = serialize(player_id, snapshot_buffer) # save authoritative state
Position.column = 999 # a mispredicted divergence
apply(player_id, snapshot_buffer, byte_count) # reconcile back to the snapshot
print(Position.column) # 3 — restored
}
}
}
```

View file

@ -1,33 +0,0 @@
---
id: fn-is_owner
name: is_owner
category: networking
kind: builtin
tokens: is_owner
sig: is_owner(target) -> bool
tip: Whether the local peer owns a model instance.
order: 50
---
<code>is_owner</code> reports whether the local peer owns a given model instance — it compares the instance's stored owner (see <code>owner</code>) against <code>local_id</code> and returns the result. It only makes sense for instances of an <code>@Owned</code> model, and it is what <code>@Predicted</code> handler dispatch consults to decide whether the owning client should run a control handler speculatively. Prefer this over reading <code>owner</code> and comparing ids yourself. A freshly spawned, unowned instance returns false everywhere until the authority assigns it with <code>set_owner</code>.
Parameters:
- `target` — the model instance (an `entity`) to test
```ludic
program CheckOwnership {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 5, row: 6 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
set_owner(player_id, 7)
print(is_owner(player_id)) # 0 — local id 0 does not own peer 7's instance
set_owner(player_id, 0)
print(is_owner(player_id)) # 1 — now the local peer owns it
}
}
}
```

View file

@ -1,28 +0,0 @@
---
id: fn-is_server
name: is_server
category: networking
kind: builtin
tokens: is_server
sig: is_server() -> bool
tip: Whether this peer is the authority.
order: 50
---
<code>is_server</code> returns whether the local peer is currently acting as the authority. The networking runtime sets the peer's role register (via <code>set_role</code>); offline it defaults to server, so an un-networked build reports <code>true</code> and every role guard collapses to "run here." This is the low-level read behind the <code>@Server</code> handler annotation — and in ordinary gameplay code you should reach for the annotation instead, since scattering <code>is_server()</code> branches through your simulation is exactly the readability footgun the role annotations exist to remove. Use the raw check only when you are driving the loop yourself.
```ludic
program AuthorityGate {
property Score { total: int = 0 }
model Scoreboard { Score }
@Server handler AwardPoints phase Update {
for (Score) in query [Score] { Score.total = Score.total + 10 }
}
entry {
spawn Scoreboard { Score { total: 0 } }
if is_server() { print(1) } else { print(0) } # 1 offline — defaults to authority
}
}
```

View file

@ -1,28 +0,0 @@
---
id: fn-local_id
name: local_id
category: networking
kind: builtin
tokens: local_id
sig: local_id() -> int
tip: This peer's own network id.
order: 50
---
<code>local_id</code> returns the network id assigned to the local peer by the runtime. It is the value <code>is_owner</code> compares an instance's <code>owner</code> against, so it answers "which of these owned instances are mine?" when you drive ownership logic by hand. Offline, with no networking runtime spliced in, it defaults to <code>0</code>. You rarely need it directly — <code>is_owner</code> already folds the comparison — but it is useful when tagging spawned instances or addressing a specific peer.
```ludic
program IdentifyPeer {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 0, row: 0 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
set_owner(player_id, local_id()) # claim this instance for the local peer
print(is_owner(player_id)) # 1 — owner now equals local_id()
}
}
}
```

View file

@ -1,34 +0,0 @@
---
id: fn-net_poll
name: net_poll
category: networking
kind: builtin
tokens: net_poll
sig: net_poll(buf, cap) -> int
tip: Receive queued bytes from the transport; returns the byte count.
order: 1
---
<code>net_poll</code> copies up to <code>cap</code> waiting bytes into <code>buf</code> and returns how many actually arrived, or <code>0</code> when nothing is queued. It is the read half of the transport seam and the counterpart to <code>net_send</code>; each poll yields one datagram, so drain in a loop until it returns <code>0</code>. Once you have the bytes you fold them back into a model instance with the generated <code>apply</code> — for a self-describing frame, write the entity id into the first word on send and read it back here before applying. Like <code>net_send</code>, this is the freedom layer beneath <code>@Sync</code>; most games never call it directly.
Parameters:
- `buf` — the destination buffer to copy received bytes into
- `cap` — the maximum number of bytes to accept this call
```ludic
program ReceivePlayer {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 0, row: 0 } }
let incoming_bytes = words(512)
var received_count = net_poll(incoming_bytes, 2048)
while received_count > 0 {
let target_id = incoming_bytes[0] # entity id led the frame
apply(target_id, offset(incoming_bytes, 4), received_count - 4)
received_count = net_poll(incoming_bytes, 2048) # next datagram, if any
}
}
}
```

View file

@ -1,35 +0,0 @@
---
id: fn-net_send
name: net_send
category: networking
kind: builtin
tokens: net_send
sig: net_send(peer, buf, len)
tip: Send raw bytes to a peer over the host's transport seam.
order: 0
---
<code>net_send</code> hands <code>len</code> bytes from <code>buf</code> to the numbered <code>peer</code>, and it is the write half of Ludic's transport seam. The transport itself is a host-provided seam rather than a fixed protocol — real UDP natively, WebRTC or WebSocket on the web, or a built-in loopback in tests — so the same game runs unchanged over any of them. This is the low-level freedom layer: you normally let <code>@Sync</code> and <code>@Owned</code> generate replication for you, and reach for <code>net_send</code> only when you drive the wire yourself, typically to ship a model instance's serialized <code>@Sync</code> fields. Pair it with <code>serialize</code> to fill the buffer and with <code>net_poll</code> on the far side to receive.
Parameters:
- `peer` — the destination peer id (a network id, e.g. `0` for the authority)
- `buf` — the source buffer holding the bytes to send
- `len` — how many bytes of `buf` to send
```ludic
program ReplicatePlayer {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 3, row: 4 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
let outgoing_bytes = words(512)
outgoing_bytes[0] = player_id # entity id in the first word
let field_count = serialize(player_id, offset(outgoing_bytes, 4))
net_send(0, outgoing_bytes, 4 + field_count) # ship id + synced fields
}
}
}
```

View file

@ -1,32 +0,0 @@
---
id: fn-owner
name: owner
category: networking
kind: builtin
tokens: owner
sig: owner(target) -> int
tip: Read a model instance's network owner id.
order: 50
---
<code>owner</code> returns the network peer id that owns a model instance, or <code>-1</code> when the instance is unowned. It only means anything for instances of an <code>@Owned</code> model, which is what gives the model its owner slot (the runtime's <code>@L_owner</code> array); a fresh instance starts unowned until the authority assigns it with <code>set_owner</code>. Ownership is part of the world snapshot, so it round-trips through replication and rollback. Read it when you need the raw id; use <code>is_owner</code> when you only need to know whether the local peer owns the instance.
Parameters:
- `target` — the model instance (an `entity`) whose owner is read
```ludic
program InspectOwner {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 5, row: 6 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
print(owner(player_id)) # -1 — a fresh instance is unowned
set_owner(player_id, 7)
print(owner(player_id)) # 7 — the authority assigned peer 7
}
}
}
```

View file

@ -1,34 +0,0 @@
---
id: fn-serialize
name: serialize
category: networking
kind: builtin
tokens: serialize
sig: serialize(target, buf) -> int
tip: Pack a model instance's @Sync fields into a buffer; returns bytes written.
order: 50
---
<code>serialize</code> writes the replicated state of a model instance into <code>buf</code> and returns how many bytes it wrote. It is compiler-generated from the schema: it copies exactly the fields that are both <code>@Sync</code>-marked and participating in that instance's model, tightly packed in member-then-field order, and nothing else. This is the dispatcher over the per-model codecs, so it works for any spawned model whose fields sync. Use it to snapshot state before shipping it with <code>net_send</code> or before a rollback; the inverse is <code>apply</code>, which reads back the identical layout, and <code>sync_size</code> gives the byte count up front so you can size a buffer.
Parameters:
- `target` — the model instance (an `entity`) whose synced fields are read
- `buf` — the destination buffer the packed bytes are written into
```ludic
program SnapshotPlayer {
@Sync property Position { column: int = 0, row: int = 0 }
property Health { @Sync current: int = 0, maximum: int = 0 }
@Owned model Player { @Sync Position, @Sync Health }
entry {
spawn Player { Position { column: 3, row: 4 }, Health { current: 50, maximum: 100 } }
for (Position, Health) in query [Position, Health, {Player}] {
let player_id = self()
let snapshot_buffer = bytes(64)
let byte_count = serialize(player_id, snapshot_buffer) # pack column, row, current
print(byte_count) # 12 = Position(8) + Health.current(4)
}
}
}
```

View file

@ -1,32 +0,0 @@
---
id: fn-set_owner
name: set_owner
category: networking
kind: builtin
tokens: set_owner
sig: set_owner(target, id)
tip: Assign a model instance's network owner.
order: 50
---
<code>set_owner</code> writes the owning peer id into a model instance's owner slot; it is how the authority hands an <code>@Owned</code> model instance to a client. Because ownership gates who may write <code>@Sync(to: owner)</code> fields and who runs <code>@Predicted</code> handlers, assignment is normally the server's job — a client assigning ownership to itself would defeat that. The value is stored in the world (the <code>@L_owner</code> array), so it survives snapshot, replication, and rollback. Pass the local peer id to make the local peer the owner, or any other peer's id to hand it off.
Parameters:
- `target` — the model instance (an `entity`) to assign
- `id` — the owning peer id to store
```ludic
program AssignOwnership {
@Sync property Position { column: int = 0, row: int = 0 }
@Owned model Player { @Sync Position }
entry {
spawn Player { Position { column: 5, row: 6 } }
for (Position) in query [Position, {Player}] {
let player_id = self()
set_owner(player_id, 0) # hand this instance to the local peer (id 0)
print(is_owner(player_id)) # 1 — the local peer now owns it
}
}
}
```