Repository-cleanup / DX pass folding three tracker items into one coherent change, verified green end to end (`bin/x test` 49/0, `bin/x selfhost-test` 29/0, `bin/x test-tools` 29/0). #28 — curate & categorise examples/ - 42 flat entries regrouped into intent-revealing subdirs: games/, rendering/, ecs/, events/, networking/, lang/, library/ (was lib/). - chronorift dir-vs-file duplication resolved: the entry file and its import modules now live together under games/chronorift(.ludic). - Every path reference updated repo-wide (test runner, editor-tool drivers, docs/site, design docs). - New examples/README.md indexes the whole set with run commands. - Showcase examples without a self-asserting entry (hello, events, net_rt) now get a compile-only rot guard in `bin/x test`, so nothing here rots silently. #30 — text-diffable golden baseline - The 4 binary selfhost/golden/*.ppm blobs are replaced by a single selfhost/golden/renders.sha256 manifest (SHA-256 per render). Hashes are byte-identical to the old PPMs, so the baseline is unchanged — only its form. - game_case now compares framebuffer hashes; a regression shows as a changed hex line in review, not "binary files differ". - New `bin/x golden` regenerates the manifest deliberately (review with `git diff selfhost/golden/renders.sha256`). #27 — PPM & asset handling - Headless renders now write build/out.ppm, never the repo root; `x app`, `x clean`, messaging and .gitignore updated to match. Nothing is written to the working root any more. - Redundant local Kenney .zip archives removed (the art ships extracted; .gitignore already excludes *.zip). CC0 License.txt files retained. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0bc5d76952
commit
fb728bbefe
73 changed files with 364 additions and 194 deletions
45
examples/networking/net_demo.ludic
Normal file
45
examples/networking/net_demo.ludic
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# net_demo.ludic — N6: a networked game end to end, in pure Ludic, no C at all
|
||||
# (NETWORKING-DESIGN §13 N6). It exercises the whole stack the earlier phases
|
||||
# built: an RPC carries client input to the authority (N4), the authority mutates
|
||||
# authoritative state (N5 roles), and the blessed runtime replicates that state
|
||||
# back to a peer that had diverged (N2 @Sync + N3 @Owned + net_rt.ludic).
|
||||
#
|
||||
# One process, one loopback transport, so the round-trips are observable. The
|
||||
# sequence a real client/server splits across machines is played here in order:
|
||||
#
|
||||
# 1. client emits Move(dx:5) — an @ToServer RPC → serialized onto the wire
|
||||
# 2. net_pump() — the authority drains it, @On(Move) applies +5
|
||||
# 3. rt_replicate(ship) — the authority ships the ship's synced state
|
||||
# 4. Pos.x = 999 — the client diverges (mispredicts)
|
||||
# 5. rt_receive() — the client reconciles to the authoritative x=5
|
||||
#
|
||||
# Prints 5 / 999 / 5. Build & run with the Ludic toolchain only:
|
||||
# bin/x app examples/networking/net_demo.ludic --headless && ./build/net_demo_headless
|
||||
import "net_rt.ludic"
|
||||
|
||||
program NetDemo {
|
||||
@Sync property Pos { x: int = 0, y: int = 0 }
|
||||
@Owned model Ship { @Sync Pos }
|
||||
|
||||
@ToServer event Move { dx: int = 0 } # client → server RPC
|
||||
@On(Move) handler DoMove { # the authority applies input
|
||||
for (Pos) in query [Pos, {Ship}] { Pos.x = Pos.x + dx }
|
||||
}
|
||||
|
||||
entry {
|
||||
spawn Ship { Pos { x: 0, y: 0 } }
|
||||
|
||||
emit Move(dx: 5) # 1. client input → wire
|
||||
net_pump() # 2. authority applies it
|
||||
|
||||
for (Pos) in query [Pos, {Ship}] {
|
||||
print(Pos.x) # 5 — server state advanced
|
||||
rt_replicate(self()) # 3. authority replicates
|
||||
Pos.x = 999 # 4. client diverges
|
||||
print(Pos.x) # 999
|
||||
}
|
||||
|
||||
rt_receive() # 5. client reconciles
|
||||
for (Pos) in query [Pos, {Ship}] { print(Pos.x) } # 5 — back to authoritative
|
||||
}
|
||||
}
|
||||
24
examples/networking/net_echo.ludic
Normal file
24
examples/networking/net_echo.ludic
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# net_echo.ludic — N0: the transport seam (NETWORKING-DESIGN §5, §13 N0).
|
||||
#
|
||||
# The transport is two calls — net_send puts a datagram on the wire, net_poll
|
||||
# takes the next one off. A production build binds them to a real socket with
|
||||
# `extern fn net_send/net_poll` (UDP native, WebRTC/WebSocket wasm); absent that,
|
||||
# the compiler supplies a built-in in-process loopback, so a program is networked
|
||||
# end to end with NO foreign host — pure Ludic. This sends four bytes and polls
|
||||
# them back through the loopback: prints 4, then 10 20 30 42.
|
||||
program NetEcho {
|
||||
entry {
|
||||
let out = bytes(4)
|
||||
out[0] = 10
|
||||
out[1] = 20
|
||||
out[2] = 30
|
||||
out[3] = 42
|
||||
net_send(0, out, 4) # onto the wire (the built-in loopback)
|
||||
|
||||
let inb = bytes(64)
|
||||
let n = net_poll(inb, 64) # take the next datagram back off
|
||||
print(n) # 4
|
||||
var i = 0
|
||||
while i < n { print(inb[i]); i = i + 1 } # 10 20 30 42
|
||||
}
|
||||
}
|
||||
25
examples/networking/net_owner.ludic
Normal file
25
examples/networking/net_owner.ludic
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# net_owner.ludic — N3: entity ownership (NETWORKING-DESIGN §6.3, §13 N3).
|
||||
#
|
||||
# `@Owned` gives a model an owner slot (the @L_owner array). owner(e) reads it,
|
||||
# set_owner(e, id) assigns it (the authority does), is_owner(e) tests it against
|
||||
# the local peer id. Ownership gates who may write @Sync(to: owner) fields and who
|
||||
# runs @Predicted handlers; it is part of the world snapshot, so it round-trips
|
||||
# through rollback/replication. A fresh entity is unowned (-1). This assigns and
|
||||
# tests ownership against the default local id (0). Prints -1 / 7 / 0 / 1.
|
||||
program NetOwner {
|
||||
@Sync property Pos { x: int = 0, y: int = 0 }
|
||||
@Owned model Unit { @Sync Pos }
|
||||
|
||||
entry {
|
||||
spawn Unit { Pos { x: 5, y: 6 } }
|
||||
for (Pos) in query [Pos, {Unit}] {
|
||||
let e = self()
|
||||
print(owner(e)) # -1 — fresh entity is unowned
|
||||
set_owner(e, 7)
|
||||
print(owner(e)) # 7 — the authority assigned it
|
||||
print(is_owner(e)) # 0 — local id 0 != 7
|
||||
set_owner(e, 0)
|
||||
print(is_owner(e)) # 1 — now the local peer owns it
|
||||
}
|
||||
}
|
||||
}
|
||||
33
examples/networking/net_roles.ludic
Normal file
33
examples/networking/net_roles.ludic
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# net_roles.ludic — N5: handler roles + the drivable sim (NETWORKING-DESIGN §6.2,
|
||||
# §5, §13 N5).
|
||||
#
|
||||
# A handler's network role is a declarative annotation, never a runtime branch in
|
||||
# ordinary code:
|
||||
# (unmarked) runs on every peer — the shared, deterministic simulation
|
||||
# @Server runs only on the authority (clients get the result via @Sync)
|
||||
# @Predicted runs on the owning client and the server (auto-reconciled)
|
||||
# The runtime sets the peer's role register (set_role); offline it defaults to
|
||||
# server, so guards collapse to "run here" and a non-networked build is unchanged.
|
||||
#
|
||||
# The per-frame phases are also exposed as callables — tick_fixed() runs the sim
|
||||
# phases — so this game owns its own loop via `entry` (for prediction/rollback,
|
||||
# replay, headless tests). Acting as a client then the server: 1, then 102.
|
||||
program NetRoles {
|
||||
property Score { n: int = 0 }
|
||||
model Board { Score }
|
||||
|
||||
handler Both phase Update { for (Score) in query [Score] { Score.n = Score.n + 1 } } # runs everywhere
|
||||
@Server handler ServerOnly phase Update { for (Score) in query [Score] { Score.n = Score.n + 100 } } # authority only
|
||||
|
||||
entry {
|
||||
spawn Board { Score { n: 0 } }
|
||||
|
||||
set_role(0) # act as a client
|
||||
tick_fixed() # Both(+1); ServerOnly skipped
|
||||
for (Score) in query [Score] { print(Score.n) } # 1
|
||||
|
||||
set_role(1) # act as the server
|
||||
tick_fixed() # Both(+1) + ServerOnly(+100)
|
||||
for (Score) in query [Score] { print(Score.n) } # 102
|
||||
}
|
||||
}
|
||||
29
examples/networking/net_rpc.ludic
Normal file
29
examples/networking/net_rpc.ludic
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# net_rpc.ludic — N4: remote events / RPCs (NETWORKING-DESIGN §6.4, §13 N4).
|
||||
#
|
||||
# An `event` marked @ToServer (client→server) or @ToClients (server→clients) is a
|
||||
# directional remote event — the event bus with a direction flag, no new concept.
|
||||
# At an `emit` site the POD payload is serialized as [event id][fields] and
|
||||
# net_send in its direction; net_pump() drains inbound frames and re-emits each
|
||||
# into the ordinary @On dispatch on the far side. So `emit Fire(...)` is a remote
|
||||
# call — it does not run locally; the receiver's pump runs the handler.
|
||||
#
|
||||
# Here two Fire RPCs are emitted (dir 5, dir 3). Before net_pump the handler has
|
||||
# not run (hits still 0); after, both are drained and re-emitted (5 + 3 = 8).
|
||||
program NetRpc {
|
||||
property Log { hits: int = 0 }
|
||||
model Sink { Log }
|
||||
|
||||
@ToServer event Fire { dir: int = 0 }
|
||||
@On(Fire) handler OnFire {
|
||||
for (Log) in query [Log] { Log.hits = Log.hits + dir }
|
||||
}
|
||||
|
||||
entry {
|
||||
spawn Sink { Log { hits: 0 } }
|
||||
emit Fire(dir: 5) # serialized onto the wire (not run locally)
|
||||
emit Fire(dir: 3)
|
||||
for (Log) in query [Log] { print(Log.hits) } # 0
|
||||
net_pump() # drain + re-emit both RPCs
|
||||
for (Log) in query [Log] { print(Log.hits) } # 8
|
||||
}
|
||||
}
|
||||
33
examples/networking/net_rt.ludic
Normal file
33
examples/networking/net_rt.ludic
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# net_rt.ludic — a blessed, server-authoritative replication runtime (NETWORKING
|
||||
# N6). It ties the language's networking primitives together into a batteries-
|
||||
# included default, the way tests/mod_c/mod.c proved the event ABI — but written
|
||||
# in Ludic, over the built-in transport, with no foreign code.
|
||||
#
|
||||
# This is LIBRARY POLICY, not the language (NETWORKING-DESIGN §10, §12): it picks
|
||||
# server-authoritative state replication. The seams stay open — swap this for
|
||||
# lockstep+rollback (world_save + tick_fixed on misprediction) or your own.
|
||||
#
|
||||
# Frame layout on the wire: [i32 entity id][synced field bytes]. The authority
|
||||
# calls rt_replicate(e) per entity each tick; a peer calls rt_receive() to drain
|
||||
# inbound snapshots and apply them. serialize/apply are the compiler-generated
|
||||
# @Sync codecs; net_send/net_poll are the transport seam (built-in loopback here,
|
||||
# a real socket when a program binds `extern fn net_send/net_poll`).
|
||||
|
||||
# The authority ships one entity's authoritative synced state to peers.
|
||||
function rt_replicate(e: int) -> void {
|
||||
let w = words(512)
|
||||
w[0] = e # entity id in the first word
|
||||
let n = serialize(e, offset(w, 4)) # synced fields after it
|
||||
net_send(0, w, 4 + n)
|
||||
}
|
||||
|
||||
# A peer drains every inbound snapshot and applies it to the named entity. One
|
||||
# datagram per poll (the transport is datagram-preserving), so loop until empty.
|
||||
function rt_receive() -> void {
|
||||
let w = words(512)
|
||||
var n = net_poll(w, 2048)
|
||||
while n > 0 {
|
||||
apply(w[0], offset(w, 4), n - 4) # w[0] = entity id; bytes follow
|
||||
n = net_poll(w, 2048)
|
||||
}
|
||||
}
|
||||
29
examples/networking/net_snapshot.ludic
Normal file
29
examples/networking/net_snapshot.ludic
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# net_snapshot.ludic — N1: whole-world snapshot to a memory buffer
|
||||
# (NETWORKING-DESIGN §5, §13 N1). The rollback/replication substrate.
|
||||
#
|
||||
# save()/load() snapshot the entire ECS world to a file; world_size/world_save/
|
||||
# world_load generalize the identical layout to a caller-owned memory buffer:
|
||||
# world_size() -> exact snapshot byte count
|
||||
# world_save(buf) -> bytes written (entities, components, vars)
|
||||
# world_load(buf, len) -> restore the world from those bytes
|
||||
# That is all rollback needs (save → predict → on misprediction restore and
|
||||
# re-sim) and all state replication needs (snapshot → ship → apply). This program
|
||||
# spawns a Unit (hp 50), snapshots the world, mutates hp to 7, then restores — hp
|
||||
# reads back 50. Prints 50 / 7 / 50, driven entirely from Ludic (no C host).
|
||||
program NetSnapshot {
|
||||
property Health { hp: int = 0, max: int = 0 }
|
||||
model Unit { Health }
|
||||
|
||||
entry {
|
||||
spawn Unit { Health { hp: 50, max: 100 } }
|
||||
let buf = bytes(world_size())
|
||||
for (Health) in query [Health, {Unit}] {
|
||||
print(Health.hp) # 50
|
||||
let n = world_save(buf) # snapshot the whole world
|
||||
Health.hp = 7
|
||||
print(Health.hp) # 7
|
||||
world_load(buf, n) # roll the world back
|
||||
print(Health.hp) # 50 — restored from bytes
|
||||
}
|
||||
}
|
||||
}
|
||||
40
examples/networking/net_sync.ludic
Normal file
40
examples/networking/net_sync.ludic
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# net_sync.ludic — N2: @Sync replication codegen (NETWORKING-DESIGN §6.1, §13 N2).
|
||||
#
|
||||
# Replication is opt-in at the field level and per model use-site. All three
|
||||
# granularities here:
|
||||
# @Sync property Position — every field of Position is replicable
|
||||
# Health { @Sync hp, max } — only hp is replicable; max never is
|
||||
# @Sync Position in Player — Position participates → x, y replicate
|
||||
# Position in Prop — not @Sync here → Prop's Position does NOT replicate
|
||||
#
|
||||
# The compiler generates per-model serialize/apply over exactly the replicable-
|
||||
# and-participating fields, plus by-kind dispatchers: sync_size(e) / serialize(e,
|
||||
# buf) / apply(e, buf, len). This snapshots a Player's synced fields, mutates all
|
||||
# of them, then applies the snapshot: synced fields (x, y, hp) restore; the
|
||||
# unsynced one (max) keeps its mutation. Prints 12 (bytes) / 3 4 50 999.
|
||||
program NetSync {
|
||||
@Sync property Position { x: int = 0, y: int = 0 } # all fields replicable
|
||||
property Health { @Sync hp: int = 0, max: int = 0 } # only hp replicable
|
||||
|
||||
@Owned model Player { @Sync Position, @Sync Health }
|
||||
model Prop { Position } # Position not @Sync here → no replication
|
||||
|
||||
entry {
|
||||
spawn Player { Position { x: 3, y: 4 }, Health { hp: 50, max: 100 } }
|
||||
for (Position, Health) in query [Position, Health, {Player}] {
|
||||
let e = self()
|
||||
let buf = bytes(64)
|
||||
print(sync_size(e)) # 12 = Position(x,y)=8 + Health.hp=4
|
||||
let n = serialize(e, buf) # snapshot the synced fields
|
||||
Position.x = 99 # mutate everything
|
||||
Position.y = 88
|
||||
Health.hp = 7
|
||||
Health.max = 999 # max is NOT synced
|
||||
apply(e, buf, n) # restore from the snapshot
|
||||
print(Position.x) # 3 — restored
|
||||
print(Position.y) # 4 — restored
|
||||
print(Health.hp) # 50 — restored
|
||||
print(Health.max) # 999 — kept (unsynced)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue