chore(repo): DX cleanup — categorise examples, text-diffable golden, build/ output (#27 #28 #30)

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:
Orkun ÇAKILKAYA 2026-08-30 18:54:21 +03:00
parent 0bc5d76952
commit fb728bbefe
73 changed files with 364 additions and 194 deletions

View 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
}
}

View 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
}
}

View 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
}
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View 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
}
}
}

View 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)
}
}
}