Implement the rest of NETWORKING-DESIGN.md (N2–N6) and eliminate every
`.c` file from the repo. clang remains only the LLVM-IR assembler; no C
is compiled anywhere.
Networking (selfhost/emit_net.ludic + parser/emit changes):
- N2 @Sync: per-model serialize/apply + by-kind dispatchers; POD-scalar
compile error and empty-participation warning; selective replication.
- N3 @Owned: @L_owner array + owner/set_owner/is_owner; owners snapshot.
- N4 @ToServer/@ToClients remote events: framed net_send + net_pump re-emit.
- N5 @Server/@Predicted role guards + drivable sim (tick_fixed/tick_render,
entry-owns-the-loop).
- Built-in loopback transport so multiplayer runs with zero foreign code;
extern fn net_send/net_poll still overrides it for a real socket.
- N6 blessed runtime (examples/net_rt.ludic) + end-to-end demo (net_demo).
- Fix: llty("entity") is now i32 (entities are i32 handles), so let e = self().
C elimination:
- Networking + foreign-mod-ABI tests rewritten as self-contained pure-Ludic
programs (examples/net_*, world_*, mod_events, scoped); tests/ removed.
- Reflection ABI exposed to Ludic as world_* builtins (Ludic-to-Ludic modding).
- Formatter rewritten C→Ludic: tools/ludic-tools/fmt.ludic.
- Language server rewritten C→Ludic: tools/ludic-tools/lsp.ludic (lexer, index
parser, cross-file workspace resolver, JSON, all LSP handlers).
- Obsolete migrate_*.c codemods deleted; ludic_syntax.h kept as vocabulary data.
Suites: ./test.sh 44/44, ./tools/test-tools.sh 28/28 (LSP 42/42), fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
505 lines
29 KiB
Markdown
505 lines
29 KiB
Markdown
# Networking, from primitives up — a design doc
|
||
|
||
> **Status: N0–N6 all shipped.** The whole stack is implemented and self-hosted,
|
||
> and — unlike the original N0/N1 which linked C hosts — every phase now runs as a
|
||
> self-contained **pure-Ludic** program (no `.c`, no foreign host): a built-in
|
||
> loopback transport fills the seam, and each `examples/net_*.ludic` drives and
|
||
> asserts itself from its own `entry`. See `test.sh` (checks `net_echo` … `net_demo`)
|
||
> and `examples/net_demo.ludic` for a full RPC→authority→replicate→reconcile loop.
|
||
> clang remains only as the LLVM-IR assembler/linker (no C is compiled), the floor
|
||
> Rust and Swift stand on.
|
||
>
|
||
> _Historical note:_ **N0 + N1 shipped first; N2–N6 were design.** Two phases landed as `test.sh`
|
||
> checks. **N0 (transport seam):** `extern fn` now lowers end to end — a direct
|
||
> `@<sym>` call plus a `declare`, no networking logic in the compiler — so the whole
|
||
> transport is two externs (`net_send`/`net_poll`) a host fills. Proven by
|
||
> [`examples/net_echo.ludic`](examples/net_echo.ludic) sending four bytes through
|
||
> the loopback host in [`tests/net_c/loopback.c`](tests/net_c/loopback.c) and
|
||
> polling them back (`4 10 20 30 42`). **N1 (snapshot-to-buffer):**
|
||
> `world_size()`/`world_save(buf)`/`world_load(buf, len)` generalize `save()`/`load()`
|
||
> from a file to a caller-owned memory buffer — the same block layout via `memcpy` —
|
||
> so the whole ECS world round-trips through bytes. Proven by
|
||
> [`examples/net_snapshot.ludic`](examples/net_snapshot.ludic) +
|
||
> [`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) (snapshot, mutate,
|
||
> restore → `50 7 50`). Both are byte-identical when unused, so the offline dividend
|
||
> (§8) holds. This is a companion to
|
||
> [EVENTS-DESIGN.md](EVENTS-DESIGN.md), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md),
|
||
> and [SCENES-DESIGN.md](SCENES-DESIGN.md). Where the events work made Ludic
|
||
> *moddable*, this proposes making it *networked* — and it deliberately does **not**
|
||
> ship a multiplayer framework. Ludic is a language: it exposes the low-level
|
||
> mechanism (transport seam, world snapshot, generated serializers, ownership, a
|
||
> drivable sim) and a thin high-level *declarative* layer that lowers onto that
|
||
> mechanism, and it leaves the netcode *policy* (authority, prediction, relevancy)
|
||
> to the developer or a library. §14 lists the open decisions.
|
||
|
||
---
|
||
|
||
## 1. Thesis
|
||
|
||
Every networking model dies on one of two problems: **determinism** or **state
|
||
serialization**. Ludic already solves both, almost by accident.
|
||
|
||
- **Determinism** is designed in — seeded RNG, `fixed` (Q16.16) instead of floats,
|
||
byte-identical golden renders, and (as of [EVENTS-DESIGN EV6](EVENTS-DESIGN.md))
|
||
bounded, array-ordered event dispatch. A modded, event-driven Ludic game still
|
||
replays identically. That is exactly the property lockstep multiplayer needs, and
|
||
the reason Factorio's heavily-modded multiplayer stays in sync.
|
||
- **State serialization** already exists — `save()`/`load()` snapshot the *entire*
|
||
ECS World to a byte buffer ([`selfhost/emit_save.ludic`](selfhost/emit_save.ludic)),
|
||
and the world-table schema built for [EVENTS-DESIGN EV2](EVENTS-DESIGN.md) (prop →
|
||
field → offset) is exactly the descriptor you serialize against.
|
||
|
||
So networking is not a new subsystem. It is a **fourth lens on the event + world
|
||
layer** — the same layer modding used. And it obeys the same two-altitude rule as
|
||
everything else in Ludic:
|
||
|
||
> **Low-level is freedom; high-level is developer experience; they are the same
|
||
> feature at two altitudes.** `@Queries` lowers to a query loop, `scene` lowers to a
|
||
> machine, `@Public @OnSpawn` lowers to `emit`. Networking's high-level annotations
|
||
> lower to a transport seam, generated serializers, and a drivable sim — and the
|
||
> primitives stay exposed underneath for anyone the sugar doesn't fit.
|
||
|
||
The developer writes **one simulation**, declares *what* replicates, *who* owns
|
||
each entity, and *where* each handler runs — and never branches on `is_server()`
|
||
in ordinary code. The compiler lowers the declarations; a networking *runtime*
|
||
(the seam-filler, like `rt_*` for windowing) supplies the transport and the tick.
|
||
|
||
---
|
||
|
||
## 2. Two altitudes, one system
|
||
|
||
| Altitude | Who writes it | Surface |
|
||
|---|---|---|
|
||
| **High-level (DX)** | the developer, declaratively | `@Sync` (field/property/model), `@Owned`, `@Server`/`@Predicted`, directional remote events |
|
||
| **Lowering** | the compiler | per-model serializers, role-guarded dispatch, remote-event send/recv, ownership storage |
|
||
| **Runtime seam** | a networking library (blessed or custom) | binds the socket, sets `role`, drives the replication tick |
|
||
| **Low-level (freedom)** | power users, when the sugar doesn't fit | `net_send`/`net_poll`, `world_save`/`world_load`, generated `serialize_*`/`apply_*`, `owner()`, the drivable sim |
|
||
|
||
Everyone lives at the top row for normal games; the bottom row stays open for
|
||
someone building something no framework could express. The split that keeps this a
|
||
*language* and not a *framework*: **annotations and their lowering are the language;
|
||
the replication driver and the transport are a library.** It is precisely the
|
||
events story — `@On`/`emit` are the language, the *modding system* is library code —
|
||
applied again.
|
||
|
||
---
|
||
|
||
## 3. Research digest — the one idea to steal from each
|
||
|
||
| System | The transferable idea |
|
||
|---|---|
|
||
| **Quake / QuakeWorld** | The founding pattern: **client-side prediction + server reconciliation**, and delta-compressed snapshots against the last acked baseline. Predict locally, correct from the authority. |
|
||
| **Source (Valve)** | **Entity interpolation** (render remote entities slightly in the past, smoothly) paired with **lag compensation** (the server rewinds to the shooter's view for hit detection). Interpolation and rewind are two halves of one clock discipline. |
|
||
| **Unity NGO** (GameObject) | `NetworkVariable<T>` with **read/write permissions** + `OnValueChanged`; ownership as `OwnerClientId`. Also the **anti-pattern to avoid**: `IsServer`/`IsOwner` branching sprinkled through gameplay code. |
|
||
| **Unity Netcode for Entities** (ghosts) | The model Ludic is closest to: **replication is a compile-time property of components and fields** — `[GhostField]`, `[GhostComponent]`, `GhostOwner`, and `Predicted`/`Interpolated` ghost modes — with serializers *generated* from the ECS schema. |
|
||
| **Mirror / FishNet** | The community-ergonomic take: `SyncVar` with change **hooks**, and clean **directional RPCs** — `Command` (client→server) / `ClientRpc` (server→clients). |
|
||
| **GGPO / rollback** | Save state → predict → on misprediction **restore and re-simulate**. Its one hard requirement is *cheap, complete state snapshot/restore* — which Ludic already has in `save()`/`load()`. |
|
||
| **Factorio** | Fully **deterministic lockstep** for heavy mod multiplayer: only *inputs* cross the wire; the whole sim is reproduced. Proof that determinism (EV6) is the enabler, not a nicety. |
|
||
| **Photon Quantum** | A shipping product that *is* deterministic-ECS-rollback. Validates the exact combination — ECS + determinism + rollback — Ludic is already positioned for. |
|
||
| **Roblox** | The **local/remote split** (`BindableEvent` vs `RemoteEvent`), server-authority by default, and engine-replicated properties: "some state just replicates, and RPCs are directional events." |
|
||
|
||
Six **footguns** the survey warns against, to design *out* from the start:
|
||
|
||
1. **Role branching everywhere.** `if (IsServer)` scattered through gameplay is the
|
||
NGO readability tax. Fix: **role is a handler annotation** (`@Server`/`@Predicted`),
|
||
never a runtime branch in ordinary code.
|
||
2. **Float nondeterminism.** Lockstep breaks the instant the networked sim touches
|
||
`f32` across platforms. Fix: the determinism contract (§11) — the networked sim
|
||
stays `int`/`fixed`.
|
||
3. **Replicating pointers / heap refs.** A `ptr` field holds a machine-local
|
||
address; it cannot cross the wire. Fix: **the compiler rejects `@Sync` on a
|
||
non-POD-scalar field** — a checked guarantee, not a convention.
|
||
4. **Sending everything every tick.** Fix: `@Sync` is **opt-in at the field level**
|
||
(only marked fields replicate), plus change-driven dirty tracking (`@OnChange`,
|
||
[LIFECYCLE LC2](LIFECYCLE-DESIGN.md)) so an unchanged field costs nothing.
|
||
5. **Hidden authority.** Magic "the server decides" behavior is unclear and
|
||
unauditable. Fix: **explicit** `@Server`/`@Predicted`; unmarked code runs
|
||
everywhere by definition.
|
||
6. **Schema-less snapshots.** A raw state blob with no version desyncs silently on a
|
||
version mismatch. Fix: the **world-table schema is the versioned descriptor** the
|
||
serializer is generated against.
|
||
|
||
---
|
||
|
||
## 4. What Ludic already has
|
||
|
||
The substrate is unusually complete for an engine that has never networked:
|
||
|
||
- **A deterministic simulation** — seeded RNG, `fixed` math, ordered ECS iteration,
|
||
EV6-bounded event dispatch. Lockstep's precondition.
|
||
- **World snapshot/restore** — `save()`/`load()` serialize the whole World
|
||
([emit_save.ludic](selfhost/emit_save.ludic)); today to a file, trivially
|
||
retargetable to a memory buffer. Rollback's precondition.
|
||
- **A reflective world table** — `ludic_get`/`set`/`has`/`query`/`register_prop`
|
||
and the prop→field→offset schema (EV2/EV2b). The apply-and-serialize substrate.
|
||
- **An event bus with a foreign ABI and POD payloads** (EV0). Directional remote
|
||
events (RPCs) are one flag on this.
|
||
- **The `rt_*` seam pattern** — the compiler already emits calls to
|
||
`rt_init`/`rt_poll`/`rt_present` that a runtime library fills. Networking's
|
||
transport and role registers plug into the identical seam.
|
||
|
||
What is missing is small and named: a transport seam, snapshot-to-*buffer*,
|
||
generated per-field serializers, ownership storage, role-guarded dispatch, and a
|
||
developer-drivable loop. Each is a phase in §13.
|
||
|
||
---
|
||
|
||
## 5. The low-level primitives (the freedom layer)
|
||
|
||
Unopinionated, composable, host- or developer-owned. A power user builds any model
|
||
directly from these; the high-level layer (§6) is sugar over them.
|
||
|
||
| Primitive | Signature (sketch) | Enables |
|
||
|---|---|---|
|
||
| **Transport seam** | `extern fn net_send(peer: int, buf: ptr, len: int)` · `extern fn net_poll(buf: ptr, cap: int) -> int` | any model; host binds UDP (native) or WebRTC/WebSocket (wasm), or a loopback for tests |
|
||
| **World snapshot ↔ buffer** | `world_save(buf: ptr) -> int` · `world_load(buf: ptr, len: int)` | rollback, replication, join/resync — generalizes `save()`/`load()` off the filesystem |
|
||
| **Generated serializers** | `serialize_<Model>(e: entity, buf: ptr) -> int` · `apply_<Model>(e: entity, buf: ptr, len: int)` | per-model, touch only the `@Sync` fields; emitted from the schema |
|
||
| **Ownership** | `owner(e: entity) -> int` · `set_owner(e: entity, id: int)` | authority checks, per-entity owner metadata (an `@L_owner` array, like `@L_kind`) |
|
||
| **Role registers** | `is_server() -> bool` · `is_owner(e: entity) -> bool` · `local_id() -> int` | the runtime sets these; role-guarded dispatch reads them |
|
||
| **Drivable sim** | `tick_fixed()` · `tick_render()` · seed get/set | a developer-owned loop for prediction/rollback (also: replay, headless tests, AI) |
|
||
| **Remote-event serde** | `emit`-site serialize + `net_send`; inbound bytes rebuild + re-`emit` | RPCs |
|
||
|
||
Transport is the one that needs *no* language work at all — a developer can already
|
||
`extern fn` a socket library and link it, exactly as the windowing layer is linked.
|
||
The language's genuine contributions are snapshot-to-buffer, the generated
|
||
serializers, ownership storage, and the drivable loop.
|
||
|
||
```ludic
|
||
# doc-check: skip — the freedom layer, a hand-rolled replication tick
|
||
entry {
|
||
while running() {
|
||
if is_server() {
|
||
for (Transform) in query [Transform, Owned] {
|
||
let n = serialize_Player(self(), buf) # compiler-generated
|
||
net_send(ALL, buf, n) # developer's transport
|
||
}
|
||
} else {
|
||
let n = net_poll(buf, CAP)
|
||
if n > 0 { apply_Player(target_of(buf), buf, n) }
|
||
}
|
||
tick_render(); present()
|
||
}
|
||
}
|
||
```
|
||
|
||
This *works*, but it is deliberately not how most games should be written — it puts
|
||
serialization and role branching in the developer's face. That is what §6 fixes.
|
||
|
||
---
|
||
|
||
## 6. The high-level DX layer (the default)
|
||
|
||
The developer declares **what** replicates, **who** owns, and **where** handlers
|
||
run. No serialization, no transport, no `is_server()` in ordinary code.
|
||
|
||
### 6.1 `@Sync` — what replicates, at three granularities
|
||
|
||
Replication is **opt-in at the field level**: a field crosses the wire only when it
|
||
is explicitly marked. There is no `@NoSync` — the surface is purely additive.
|
||
|
||
Two independent switches, and **both must be on** for a field to replicate:
|
||
|
||
1. **A field is *replicable*** iff it is `@Sync`-marked — directly
|
||
(`@Sync hp: int`), or via `@Sync property P { … }` (a shorthand that marks
|
||
*every* field of `P` replicable). *Only marked fields — never all-by-default.*
|
||
2. **A component *participates* in a model** iff the model marks it `@Sync`
|
||
(`@Sync Transform` inside the `model`). Participation is decided **per model
|
||
use-site**, so the same property syncs in one model and not another.
|
||
|
||
A field of an entity replicates **iff it is replicable AND its component
|
||
participates in that entity's model.**
|
||
|
||
```ludic
|
||
# doc-check: skip — the three levels
|
||
@Sync property Position { x: int, y: int } # every field of Position is replicable
|
||
property Health { @Sync hp: int, max: int } # only hp is replicable; max never is
|
||
property Transform { @Sync x: int, @Sync y: int, angle: int } # x, y replicable; angle not
|
||
|
||
@Owned model Player { # entities carry a network owner
|
||
@Sync Transform # participates → replicates x, y (not angle)
|
||
@Sync Health # participates → replicates hp (not max)
|
||
@Sync Position # participates → replicates x, y
|
||
}
|
||
|
||
model Prop { # a non-owned decoration
|
||
Transform # not @Sync here → Transform does NOT replicate — the
|
||
# "non-synced Transform sometimes" case, for free
|
||
}
|
||
```
|
||
|
||
- **Checked, not silent.** `@Sync` on a `ptr`/non-POD-scalar field is a **compile
|
||
error** ("networked fields must be POD scalars" — footgun 3). A model that
|
||
`@Sync`es a component with *zero* replicable fields is a **compile warning**
|
||
(participation that replicates nothing).
|
||
- **Per-field direction** rides the same annotation as an argument, mirroring how
|
||
`@Queries(these:…, on:…)` takes args: `@Sync(to: owner) hp: int` replicates a
|
||
field only to the entity's owner (Unity's `SendToOwner`). Default is `to: all`.
|
||
|
||
### 6.2 Roles — where a handler runs
|
||
|
||
The role is a **declarative annotation on the handler**, never a runtime branch.
|
||
Unmarked code is the shared, deterministic simulation and runs everywhere.
|
||
|
||
| Annotation | Runs where | Meaning |
|
||
|---|---|---|
|
||
| *(none)* | everywhere | shared, deterministic simulation |
|
||
| **`@Server`** | the authority only | server-authoritative logic; clients receive the result via `@Sync` |
|
||
| **`@Predicted`** | the owning client (speculatively) **and** the server (authoritatively) | responsive local control, auto-reconciled against the server |
|
||
|
||
`@Predicted` is **explicit** — the developer opts an owned entity's control handlers
|
||
into prediction; the language does not silently predict. The name states the netcode
|
||
role (owner-predicts + server-authoritative + reconcile), not the machine, and
|
||
matches Unity's `GhostMode.Predicted` so the concept transfers.
|
||
|
||
`@Interpolated` — how a *non-owned* synced component is smoothed between snapshots on
|
||
a remote client — is a **presentation** concern on the component, kept separate from
|
||
these sim-handler roles rather than muddying them.
|
||
|
||
### 6.3 Ownership
|
||
|
||
```ludic
|
||
# doc-check: skip
|
||
@Owned model Player { @Sync Transform; @Sync Health } # every Player entity has a network owner
|
||
```
|
||
|
||
`@Owned` gives the model an owner slot (the `@L_owner` array); `owner(e)` /
|
||
`set_owner(e, id)` read and assign it (the authority assigns). `is_owner(e)` and
|
||
`@Predicted` dispatch read it. Ownership gates who may write `@Sync(to: owner)`
|
||
fields and who runs `@Predicted` handlers.
|
||
|
||
### 6.4 RPCs are directional remote events
|
||
|
||
RPCs are the event bus with a direction flag — no new concept:
|
||
|
||
```ludic
|
||
# doc-check: skip
|
||
@ToServer event Fire { dir: int } # client → server (a request)
|
||
@ToClients event Boom { x: int, y: int } # server → clients (a broadcast)
|
||
|
||
@Server @On(Fire) handler DoFire { spawn Bullet { dir: Fire.dir } } # authority handles the request
|
||
@On(Boom) handler Vfx { spawn Explosion { x: Boom.x, y: Boom.y } } # every client reacts
|
||
```
|
||
|
||
`@ToServer`/`@ToClients` mark an `event` remote; the compiler serializes its POD
|
||
payload (already flat — [EVENTS-DESIGN EV0](EVENTS-DESIGN.md)) and routes it through
|
||
the transport seam in the declared direction, re-`emit`ting it on the far side into
|
||
the ordinary event dispatch.
|
||
|
||
### 6.5 The whole game, high-level
|
||
|
||
```ludic
|
||
# doc-check: skip — read top to bottom: you always know where each line runs
|
||
program Shooter {
|
||
@Sync property Position { x: int, y: int }
|
||
property Health { @Sync hp: int, max: int }
|
||
|
||
@Owned model Player { @Sync Position; @Sync Health }
|
||
model Bullet { Position }
|
||
|
||
handler Physics phase FixedUpdate { … } # no tag → shared, identical everywhere
|
||
@Predicted handler Move phase Input { … } # owner predicts, server authoritative
|
||
@Server handler Death phase Update { … } # authority only; clients get the result via @Sync
|
||
|
||
@ToServer event Fire { dir: int }
|
||
@Server @On(Fire) handler DoFire { spawn Bullet { … } }
|
||
}
|
||
```
|
||
|
||
No `is_server()`, no `net_send`, no serializer — yet every line's role is legible,
|
||
and every replicated field is explicitly opted in.
|
||
|
||
---
|
||
|
||
## 7. Lowering summary
|
||
|
||
Everything above reduces to the §5 primitives, gated so an un-networked build is
|
||
unchanged:
|
||
|
||
| High-level | Lowers to |
|
||
|---|---|
|
||
| `@Sync` field / `@Sync C` in a model | a per-model `serialize_<M>` / `apply_<M>` over the replicable-and-participating fields, + a `sync manifest` a runtime reads |
|
||
| `@Sync(to: owner)` | a field tag in the manifest; the serializer branches on `owner(e) == peer` |
|
||
| `@Owned` | an `@L_owner` array + `owner()`/`set_owner()`, like `@L_kind` |
|
||
| `@Server` / `@Predicted` handler | the handler's dispatch wrapped in a role guard the runtime's role register drives (the `rt_*` seam pattern) |
|
||
| `@ToServer` / `@ToClients event` | payload serialize + `net_send(direction, …)` at the `emit` site; inbound bytes rebuild + re-`emit` |
|
||
| `world_save`/`world_load` to buffer | the existing `save()`/`load()` snapshot machinery, retargeted from a file handle to a memory buffer |
|
||
| drivable `tick_fixed`/`tick_render` | the phase runners the compiler already generates for the frame loop, exposed as callables when a game owns its `entry` loop |
|
||
|
||
No heap, no hidden runtime beyond the honestly-named transport/role seams a
|
||
networking library fills — the same relationship windowing already has.
|
||
|
||
---
|
||
|
||
## 8. The offline dividend
|
||
|
||
Because these are **opt-in-cost annotations** — serializers *generated*, nothing
|
||
*run* until a networking runtime is spliced — a build with no runtime is
|
||
**byte-identical to single-player**, and every role guard collapses to "run here."
|
||
You build the game offline, drop in a runtime, and the same annotated code starts
|
||
replicating. That is Unity's "offline mode adjustable," achieved by the same
|
||
opt-in-cost invariant the whole event system already holds.
|
||
|
||
---
|
||
|
||
## 9. The one genuinely hard corner
|
||
|
||
Determinism holds beautifully for `int`/`fixed` simulations, which makes lockstep
|
||
and rollback cheap. It **breaks for `f32` across platforms** — so **3D/voxel +
|
||
lockstep stays the hard corner** (3D wants floats; the Luanti analysis flagged that
|
||
`fixed` saturates at ±32768). No language sleight-of-hand fixes this; the
|
||
determinism contract (§11) states it plainly, and a developer choosing lockstep for
|
||
a 3D game has to accept it (or choose state replication, §10's other branch, where
|
||
per-frame determinism is not required).
|
||
|
||
---
|
||
|
||
## 10. Two model families, both reachable — neither built in
|
||
|
||
The language commits to **neither**; both are library policy over the §5 primitives.
|
||
|
||
- **Deterministic lockstep / rollback** — exchange only inputs; reproduce the sim;
|
||
on misprediction, `world_load` a snapshot and re-`tick_fixed`. Plays to Ludic's
|
||
determinism, and GGPO-cheap because snapshot/restore already exists. Best for
|
||
2D/integer/fixed games.
|
||
- **State replication** — the authority `world_save`s (or per-`@Sync` serializes),
|
||
delta-encodes against the last acked snapshot per peer, ships the diff; peers
|
||
`apply_*` it and interpolate/predict. Heavier, but needed when the sim can't be
|
||
deterministic (float physics, 3D).
|
||
|
||
A **blessed reference runtime** (§13, N6) can ship one of these so `@Sync` games
|
||
work out of the box — the way [`tests/mod_c/mod.c`](tests/mod_c/mod.c) proved the
|
||
event ABI — while the seams stay open for others.
|
||
|
||
---
|
||
|
||
## 11. The determinism contract (what the language must guarantee)
|
||
|
||
For a developer to *trust* lockstep, the language must promise, document, and where
|
||
possible *enforce*:
|
||
|
||
1. **`fixed`/`int` math is bit-identical across platforms.** The networked sim must
|
||
avoid `f32` (footgun 2). *(Enforcement: at least a documented rule; ideally a
|
||
`@Sync`/`@Server`-reachable-code float lint.)*
|
||
2. **ECS iteration order is stable** — query order is declaration/id order, and
|
||
EV6 already fixes event-dispatch order. No hash-map iteration in the sim path.
|
||
3. **RNG is deterministic from a shared seed** — `seed()` exists; the seed must be
|
||
synchronized at session start (library policy) and never re-seeded from
|
||
wall-clock mid-sim.
|
||
4. **Networked components are POD scalars** — no `ptr`/heap fields cross the wire
|
||
(footgun 3). *Enforced:* `@Sync` on a non-scalar field is a compile error.
|
||
5. **Entity ids agree across peers** — lockstep gets this free from determinism;
|
||
replication needs an id-mapping table (library policy).
|
||
|
||
This contract is the language's real networking responsibility. Most of it is
|
||
*already true*; the work is stating and enforcing it, not inventing it.
|
||
|
||
---
|
||
|
||
## 12. Design principles
|
||
|
||
1. **Mechanism in the language, policy in the library.** Expose serializers,
|
||
transport seam, ownership, snapshot, drivable sim. Never bake in authority,
|
||
prediction, or matchmaking.
|
||
2. **Role is declared, not branched.** `@Server`/`@Predicted` on handlers; unmarked
|
||
code runs everywhere. No `is_server()` in ordinary gameplay.
|
||
3. **Replication is explicit and opt-in.** Only `@Sync`-marked fields cross the
|
||
wire; participation is decided per model. Nothing replicates by surprise.
|
||
4. **Opt-in cost.** Un-networked builds are byte-identical; the sim runs offline
|
||
with the same code.
|
||
5. **Determinism is a promise the language keeps.** Enforce the POD-scalar rule;
|
||
document the float/iteration/seed rules; keep the sim reproducible.
|
||
6. **Two altitudes, always.** The high-level lowers to primitives that stay
|
||
callable. The sugar is the default; the freedom layer is never removed.
|
||
7. **Reuse, don't reinvent.** Snapshot = generalized `save()`; RPC = directional
|
||
`event`; serializer = generated from the EV2 schema; role seam = the `rt_*`
|
||
pattern. Networking is the fourth lens, not a parallel stack.
|
||
|
||
---
|
||
|
||
## 13. Suggested implementation order
|
||
|
||
Each phase is independently shippable and testable, matching how the repo phases
|
||
work (and how EVENTS-DESIGN sequenced EV0–EV7).
|
||
|
||
- **N0 — transport seam + loopback. ✅ SHIPPED.** The `net_send`/`net_poll` extern
|
||
seam and a loopback host stub; an echo test. The floor; needed almost no compiler
|
||
work — just finishing `extern fn`: a call lowers to a direct `@<sym>` call and the
|
||
header emits a matching `declare`, so any C/Rust/Zig library (a socket, here the
|
||
loopback) binds through the same seam windowing uses. `find_extern` (emit_core),
|
||
the extern branch in emit_expr's call path, `emit_extern_decls` (emit_head).
|
||
([`examples/net_echo.ludic`](examples/net_echo.ludic),
|
||
[`tests/net_c/loopback.c`](tests/net_c/loopback.c) → `4 10 20 30 42`.)
|
||
- **N1 — snapshot-to-buffer. ✅ SHIPPED.** Generalized `save()`/`load()` to a memory
|
||
buffer: `world_size()` (exact snapshot bytes), `world_save(buf) -> int`,
|
||
`world_load(buf, len)`. The same fixed block list (entity count, freelist, alive,
|
||
kind, vars, per-component `@S_`/`@H_`) now feeds a file (fwrite/fread) *or* a buffer
|
||
(memcpy over a threaded i64 offset), chosen by `g_snap_mode` in emit_save.ludic;
|
||
no rt_ hook (the ECS world only). The rollback/replication substrate.
|
||
([`examples/net_snapshot.ludic`](examples/net_snapshot.ludic),
|
||
[`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) → `50 7 50`.)
|
||
- **N2 — `@Sync` codegen. ✅ SHIPPED.** The three-level annotations → generated
|
||
per-model `serialize_<M>`/`apply_<M>` + by-kind dispatchers (`ludic_serialize`/
|
||
`apply`/`sync_size`, and the `serialize`/`apply`/`sync_size` builtins); the
|
||
POD-scalar compile error and the empty-participation warning. The declarative
|
||
core. ([`examples/net_sync.ludic`](examples/net_sync.ludic) → `12 3 4 50 999`,
|
||
emit in [`selfhost/emit_net.ludic`](selfhost/emit_net.ludic).)
|
||
- **N3 — ownership. ✅ SHIPPED.** `@Owned` + the `@L_owner_arr` array +
|
||
`owner()`/`set_owner()`/`is_owner()`; owners are part of the world snapshot.
|
||
([`examples/net_owner.ludic`](examples/net_owner.ludic) → `-1 7 0 1`.)
|
||
- **N4 — remote events (RPCs). ✅ SHIPPED.** `@ToServer`/`@ToClients` on `event`s →
|
||
payload serialize (`[event id][fields]`) + directional `net_send` + `net_pump()`
|
||
far-side re-`emit`. ([`examples/net_rpc.ludic`](examples/net_rpc.ludic) → `0 8`.)
|
||
- **N5 — roles + drivable sim. ✅ SHIPPED.** `@Server`/`@Predicted` role-guarded
|
||
dispatch driven by the `@L_role` register (`set_role`/`is_server`/`local_id`);
|
||
the opt-in `entry`-owns-the-loop with `tick_fixed()`/`tick_render()`. Together
|
||
these let prediction/rollback be written in developer/library code.
|
||
([`examples/net_roles.ludic`](examples/net_roles.ludic) → `1 102`.)
|
||
- **N6 — a blessed reference netcode runtime. ✅ SHIPPED.** A Ludic library
|
||
([`examples/net_rt.ludic`](examples/net_rt.ludic)) — server-authoritative state
|
||
replication over the primitives — plus a full end-to-end demo, proving the seams
|
||
the way the C mod proved the event ABI, but in pure Ludic over the built-in
|
||
transport. Library policy, swappable for lockstep+rollback.
|
||
([`examples/net_demo.ludic`](examples/net_demo.ludic) → `5 999 5`.) A built-in
|
||
loopback transport (N0) means all of this needs **no foreign code at all**.
|
||
|
||
N0–N2 deliver "state can be declared, serialized, and moved." N3–N4 add ownership
|
||
and RPCs. N5 unlocks prediction. N6 is a batteries-included default that others can
|
||
replace. The **determinism contract (§11)** is cross-cutting — documented from N0,
|
||
enforced incrementally.
|
||
|
||
---
|
||
|
||
## 14. Open decisions
|
||
|
||
1. **Field direction vocabulary.** `@Sync(to: owner)` / `@Sync(to: all)` confirmed
|
||
in spirit; is `to:` the right key, and do we also want `to: server` (a field only
|
||
the authority reads)? How does per-field direction interact with `@Predicted`?
|
||
2. **Blessed runtime, or seams only?** Events chose "seams + reference mod, bless
|
||
nothing." Networking's DX may justify shipping one reference runtime (N6). One,
|
||
or none?
|
||
3. **Authority default.** Server-authoritative with `@Predicted` opt-in is the safe,
|
||
Unity-ish default. Confirm, or keep the language authority-neutral and leave even
|
||
that to the runtime?
|
||
4. **Drivable loop shape.** Whole-frame `tick()` vs the `tick_fixed()`/`tick_render()`
|
||
split; how a developer-owned `entry` loop coexists with scenes, the `rt_*` hooks,
|
||
and the auto-loop (opt-in via presence of an `entry` block?).
|
||
5. **Snapshot granularity.** Full `world_save` vs per-`@Sync` serialize vs a
|
||
generated delta between two snapshots — which does the language provide, and which
|
||
is library work?
|
||
6. **Float determinism enforcement.** A documented rule only, or a real lint that
|
||
flags `f32` reachable from `@Server`/`@Predicted`/`@Sync` code paths?
|
||
7. **Ownership at component granularity.** Unity's DOTS allows per-component owner
|
||
send-rules. Is `@Owned` per-*entity* enough, or do we need per-component owners
|
||
(a real complexity jump)?
|
||
8. **Networking substrate for the remote half of EVENTS EV7.** This doc's directional
|
||
remote events (N4) *are* the local/remote split EVENTS-DESIGN EV7 deferred for
|
||
"no networking substrate." N4 is that substrate — the two docs meet here.
|
||
|
||
---
|
||
|
||
*Companion to [EVENTS-DESIGN.md](EVENTS-DESIGN.md) (remote events are directional
|
||
events; serializers reuse the EV2 world-table schema; EV7's deferred local/remote
|
||
split lands here as N4), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (`@OnChange`/LC2
|
||
is the dirty-tracking primitive for delta replication), and
|
||
[SCENES-DESIGN.md](SCENES-DESIGN.md). Supersedes nothing until the compiler work in
|
||
§13 lands.*
|