Networking N2–N6, and a fully C-free toolchain

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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-29 15:08:23 +03:00
parent 96d01e45ab
commit bca8f126fc
67 changed files with 24066 additions and 9309 deletions

27
examples/cancel.ludic Normal file
View file

@ -0,0 +1,27 @@
# cancel.ludic — EV3: cancellable (decision) events.
#
# A `cancellable` event is fired BEFORE an action so a listener can veto it. The
# listener calls `cancel`; the caller reads the verdict back — `emit E(…)` used as
# an expression yields the cancelled flag (1 = vetoed, 0 = allowed). A foreign mod
# vetoes the same way, by setting the payload's trailing `cancelled` field over
# the ABI. This is the modding headline: observation becomes control.
#
# Running it prints: 0 1 92
program Cancel {
event cancellable BeforeHurt { amount: int = 0 }
@On(BeforeHurt) handler Armor { if amount > 10 { cancel } } # veto any hit over 10
entry {
let v1 = emit BeforeHurt(amount: 5) # 5 <= 10 -> allowed
print(v1) # 0
let v2 = emit BeforeHurt(amount: 15) # 15 > 10 -> vetoed
print(v2) # 1
# the realistic shape: only apply the effect when the decision isn't vetoed
var hp = 100
if emit BeforeHurt(amount: 8) == 0 { hp = hp - 8 } # allowed -> 92
if emit BeforeHurt(amount: 50) == 0 { hp = hp - 50 } # vetoed -> unchanged
print(hp) # 92
}
}

38
examples/detach.ludic Normal file
View file

@ -0,0 +1,38 @@
# detach.ludic — the structural attach/detach pair and its @OnAttach / @OnDetach
# hooks. `attach P on e` adds a property to a LIVE entity (seeding its fields and
# firing @OnAttach); `detach P on e` removes it (firing @OnDetach, which still
# reads the outgoing value before the has-flag clears). This is the structural
# counterpart to enable/disable — attach/detach create and destroy the property's
# presence, whereas disable/enable only pause it while keeping the data.
#
# Running it prints: 15 1 25 0
# 15 @OnAttach(Shield): amount seeded to 5, prints 5 + 10
# 1 one live Shield now matches the query
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
# 0 the Shield is gone — nothing matches
#
# ./selfhost/game-build.sh build/ludicc examples/detach.ludic /tmp/detach
# /tmp/detach </dev/null
program Detach {
property Tag { v: int = 0 }
property Shield { amount: int = 0 }
model Unit { Tag }
@OnAttach(Shield) handler Up { print(Shield.amount + 10) } # structural: property born
@OnDetach(Shield) handler Down { print(Shield.amount + 20) } # structural: property dies
handler Seed phase Start { spawn Unit { Tag { v: 1 } } }
handler Run phase Render {
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # @OnAttach -> 15
var n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 1
for (s) in query [Shield] { detach Shield on self() } # @OnDetach -> 25
n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 0
quit()
}
}

25
examples/events.ludic Normal file
View file

@ -0,0 +1,25 @@
# events.ludic — EV0: the event bus core.
#
# `event E { fields }` declares a public event carrying a POD payload. `@On(E)
# handler …` registers a listener whose body reads the payload fields by name.
# `emit E(field: v, …)` fires the event: it calls every @On(E) listener, in
# declaration order, as a direct call — the whole thing desugars to code, with no
# runtime and no dispatch table. A program that declares no `event` is compiled
# byte-for-byte as before (the subsystem is gated on `g_events`).
#
# Running it prints: 5 8 20 30 999 42 42
program Events {
event Hurt { entity: int = 0, amount: int = 0 } # a payload with fields + defaults
event Cleared { } # an empty payload is allowed
@On(Hurt) handler Flash { print(amount) } # listeners bind payload fields by name
@On(Hurt) handler Guard { print(entity + amount) } # a second listener, run after the first
@On(Cleared) handler Cheer { print(999) }
entry {
emit Hurt(entity: 3, amount: 5) # -> Flash 5, Guard 3+5=8
emit Hurt(entity: 10, amount: 20) # -> Flash 20, Guard 10+20=30
emit Cleared() # -> Cheer 999
emit Hurt(amount: 42) # entity defaults to 0 -> Flash 42, Guard 0+42=42
}
}

View file

@ -0,0 +1,27 @@
# layer_events.ludic — EV1 for layers + SCENES E2 (layer toggle). A `public` layer
# promotes its show/hide to events; `enable layer L` / `disable layer L` flips the
# layer on and off (its handlers stop running while hidden) and fires
# layer_<L>_show / layer_<L>_hide. This closes the last scope of "events across the
# whole architecture" — properties, models, scenes, program, and now layers.
#
# Running it prints: 50 2 1 50
program LayerEvents {
var step: int = 0
@On(layer_Hud_show) handler Shown { print(1) } # a mod reacts when the HUD returns
@On(layer_Hud_hide) handler Hidden { print(2) } # ...and when it's hidden
scene Main start {
layer Hud public {
handler Draw phase Update { print(50) } # only runs while Hud is enabled
}
layer Ctrl {
handler Drive phase LateUpdate {
step = step + 1
if step == 1 { disable layer Hud } # frame 1: hide -> Hidden 2, Draw stops
if step == 2 { enable layer Hud } # frame 2: show -> Shown 1, Draw resumes
if step == 3 { quit() } # frame 3: after Draw ran again
}
}
}
}

16
examples/mod_events.ludic Normal file
View file

@ -0,0 +1,16 @@
# mod_events.ludic — the event bus with two listeners on one event, driven from
# Ludic (replaces the former tests/mod_c/mod.c foreign-mod host). A public event
# `Damage` has two @On listeners: one prints the amount, one accumulates a total.
# Emitting it twice runs both listeners each time. Prints 10 / 32 / 42.
program ModEvents {
var total: int = 0
event Damage { amount: int = 0 }
@On(Damage) handler Native { print(amount) } # prints each hit
@On(Damage) handler Accum { total = total + amount } # sums them
entry {
emit Damage(amount: 10) # Native prints 10
emit Damage(amount: 32) # Native prints 32
print(total) # 42
}
}

45
examples/net_demo.ludic Normal file
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:
# ./build.sh examples/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/net_echo.ludic Normal file
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
}
}

25
examples/net_owner.ludic Normal file
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
}
}
}

33
examples/net_roles.ludic Normal file
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
}
}

29
examples/net_rpc.ludic Normal file
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
}
}

33
examples/net_rt.ludic Normal file
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.
fn 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.
fn 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
}
}
}

40
examples/net_sync.ludic Normal file
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)
}
}
}

View file

@ -0,0 +1,18 @@
# program_events.ludic — EV1 for the program ("game") scope: @Public on @OnStart
# and @OnQuit promotes them to program_start / program_quit events, so a mod runs
# its own setup after the game boots and its own teardown as the game exits — the
# top-level modding entry points (Blender's app handlers, Minetest's on_shutdown).
#
# Running it prints: 1 100 2 200
program ProgramEvents {
property Health { hp: int = 0 }
model Dummy { Health }
@Public @OnStart handler Boot { print(1) } # -> program_start
@Public @OnQuit handler Bye { print(2) } # -> program_quit
@On(program_start) handler ModInit { print(100) } # mod boots after the game
@On(program_quit) handler ModDone { print(200) } # mod tears down as the game exits
handler Run phase Update { quit() }
}

32
examples/promote.ludic Normal file
View file

@ -0,0 +1,32 @@
# promote.ludic — EV1: `@Public` promotes a lifecycle hook to a public event.
#
# Marking a lifecycle hook `@Public` makes its fire site ALSO emit an event named
# for the moment — `model_<M>_spawn`, `model_<M>_despawn` — carrying the entity
# (and, for despawn, the EndReason). Anything can then subscribe: a native
# listener with `@On(model_Enemy_spawn)`, or a foreign mod binding
# `ludic_on_model_Enemy_spawn` over the C ABI. The game's own lifecycle becomes
# the modding surface, with no hand-written `emit` in the hook body.
#
# Running it prints: 100 101 200 201
program Promote {
property Health { hp: int = 0, max: int = 100 }
model Enemy { Health }
@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max } # -> model_Enemy_spawn
@Public @OnDespawn(Enemy) handler Clean { } # -> model_Enemy_despawn
# Listeners on the promoted events. These read the generated payload (entity,
# and reason for despawn) by name — exactly what a foreign mod receives.
@On(model_Enemy_spawn) handler Spawned { print(entity + 100) }
@On(model_Enemy_despawn) handler Died { print(entity + reason + 200) }
handler Seed phase Start {
spawn Enemy { Health { max: 50 } } # entity 0 -> Spawned 100
spawn Enemy { Health { max: 60 } } # entity 1 -> Spawned 101
}
handler Run phase Update {
for (h) in query [Health] { despawn self() } # despawn 0 -> Died 200, despawn 1 -> Died 201
quit()
}
}

View file

@ -0,0 +1,26 @@
# prop_events.ludic — EV1 for properties: @Public promotes the structural
# attach/detach hooks to public events, so a mod sees a property appear or vanish
# on a live entity (the same shape works for @OnEnable/@OnDisable → enable/disable
# events). This extends the public-event surface past models to properties —
# "events for properties, models, scenes, layers" in the architecture.
#
# Running it prints: 300 400
program PropEvents {
property Tag { v: int = 0 }
property Shield { amount: int = 0 }
model Unit { Tag }
@Public @OnAttach(Shield) handler Up { } # -> prop_Shield_attach
@Public @OnDetach(Shield) handler Down { } # -> prop_Shield_detach
@On(prop_Shield_attach) handler Gained { print(entity + 300) } # entity 0 -> 300
@On(prop_Shield_detach) handler Lost { print(entity + 400) } # entity 0 -> 400
handler Seed phase Start { spawn Unit { Tag { v: 1 } } } # entity 0
handler Run phase Render {
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # -> prop_Shield_attach -> 300
for (s) in query [Shield] { detach Shield on self() } # -> prop_Shield_detach -> 400
quit()
}
}

38
examples/reason.ludic Normal file
View file

@ -0,0 +1,38 @@
# reason.ludic — LC1 reason-carrying teardown. One @OnDespawn hook, but it knows
# *why* the entity is ending: `reason: r` binds an EndReason the compiler passes
# at each teardown site. An in-world `despawn` passes EndReason.Despawned; program
# shutdown passes EndReason.Quit (every still-live entity's hook fires at exit — no
# silent deaths). The body branches on the reason, exactly as Unreal's
# EndPlay(reason) / Erlang's terminate(Reason) do.
#
# Running it prints: 503 1009
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500
# 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
#
# ./selfhost/game-build.sh build/ludicc examples/reason.ludic /tmp/reason
# /tmp/reason </dev/null
program Reasons {
property Health { hp: int = 0 }
property Loot { gold: int = 0 }
model Enemy { Health, Loot }
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => { print(Health.hp + 1000) } # app closing — don't bother dropping loot
_ => { print(Loot.gold + 500) } # died in-world — drop the loot
}
}
handler Seed phase Start {
spawn Enemy { Health { hp: 7 }, Loot { gold: 3 } } # A
spawn Enemy { Health { hp: 9 }, Loot { gold: 4 } } # B
}
handler Run phase Render {
var first = 0
for (e) in query [Health] {
if first == 0 { despawn self(); first = 1 } # despawn A -> Despawned -> 3 + 500 = 503
}
quit() # B survives -> Quit -> 9 + 1000 = 1009
}
}

21
examples/recurse.ludic Normal file
View file

@ -0,0 +1,21 @@
# recurse.ludic — EV6: re-entrant emit is bounded. Ping's listener emits Pong,
# Pong's listener emits Ping — an event cycle that would hang the frame forever.
# The depth bound (EV_DEPTH_CAP) makes the nesting trap as an early return, so the
# program terminates with a bounded count instead of spinning. This is the "no
# runaway event cycle" guarantee.
#
# Running it prints: 16 (the cycle bottoms out at the depth cap, deterministically)
program Recurse {
var n: int = 0
event Ping { }
event Pong { }
@On(Ping) handler A { n = n + 1; emit Pong() } # each Ping deepens by one Pong
@On(Pong) handler B { emit Ping() } # ...and each Pong by one Ping
entry {
emit Ping()
print(n)
}
}

View file

@ -0,0 +1,22 @@
# scene_events.ludic — EV1 for scenes: a `public` scene promotes its on-enter /
# on-exit to public events scene_<S>_enter / scene_<S>_exit, extending the modding
# surface to the game's scene structure. A mod (native @On here, or foreign over
# the ABI) reacts as the game moves between scenes.
#
# Running it prints: 10 1 20 2 30 3
program SceneEvents {
@On(scene_Menu_enter) handler Greet { print(1) }
@On(scene_Menu_exit) handler Bye { print(2) }
@On(scene_Game_enter) handler Begin { print(3) }
scene Menu start public {
on enter { print(10) } # boot enters Menu: 10, then scene_Menu_enter -> 1
on exit { print(20) } # become leaves Menu: 20, then scene_Menu_exit -> 2
layer L { handler Go phase Update { become Game } }
}
scene Game public {
on enter { print(30) } # become enters Game: 30, then scene_Game_enter -> 3
layer M { handler Stop phase Update { quit() } }
}
}

View file

@ -1,9 +1,19 @@
# ⚠️ NOT YET IMPLEMENTED by the self-hosted compiler. `scene` / `layer` /
# `on enter` / `on exit` have no parser support today, so this file does NOT
# compile with build/ludicc — it is a design sketch of the intended syntax (see
# LANGUAGE.md §"Scenes & layers"). It is deliberately excluded from test.sh.
# Games that need scene-like states use a mode register + `machine` today, as
# examples/chronorift does.
# scenes — one active scene at a time, each grouping handlers into layers behind
# an implicit active-scene register (see LANGUAGE.md §"Scenes & layers").
#
# Running it (feed a few keystrokes so the loop ticks) prints:
# 1000 1 101 102 2 3 900 201 900 202 900
# 1000 Boot (a global handler) runs once at Start
# 1 Title is `start`; its `on enter` fires at boot
# 101 frame 1 Update: only Title.Main.Tick runs (Play is not active)
# 102 frame 2 Update: Tick reaches 2 -> `become Play`…
# 2 3 …which runs Title's `on exit` then Play's `on enter`
# 900 Play renders the same frame it is entered (Hud.Draw)
# 201 900 frame 3: Play.World.Step, then Hud.Draw
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
#
# ./selfhost/game-build.sh build/ludicc examples/scenes.ludic /tmp/scenes
# printf 'aaaa' | /tmp/scenes
program SceneDemo {
const R_N: int = 0

21
examples/scoped.ludic Normal file
View file

@ -0,0 +1,21 @@
# scoped.ludic — a despawned entity drops out of subsequent event-driven work
# (replaces tests/mod_c/scoped_mod.c, which tested entity-scoped foreign callbacks
# swept on despawn). Here a @On(Tick) listener counts live Units each tick; after
# the Unit is despawned, a further tick finds none. Fires twice before the kill,
# not the third time. Prints 2.
program Scoped {
property Health { hp: int = 0 }
model Unit { Health }
var fires: int = 0
event Tick { }
@On(Tick) handler OnTick { for (Health) in query [Health, {Unit}] { fires = fires + 1 } }
entry {
spawn Unit { Health { hp: 1 } }
emit Tick() # 1 live Unit -> fires = 1
emit Tick() # fires = 2
for (Health) in query [Health, {Unit}] { despawn self() } # kill the Unit
emit Tick() # no live Units -> fires stays 2
print(fires) # 2
}
}

27
examples/world_dyn.ludic Normal file
View file

@ -0,0 +1,27 @@
# world_dyn.ludic — EV7 reflection: register a brand-new component at runtime
# (replaces tests/mod_c/world_dyn.c). world_register_prop declares a component the
# game never wrote; world_attach_dyn adds it to an entity; get/set/has then treat
# it exactly like a built-in, with per-entity isolation. Prints 0 1 30 100 1 30.
program WorldDyn {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { }
entry {
let Um = world_model_id("Unit")
let e = world_spawn(Um)
let e2 = world_spawn(Um)
let Mana = world_register_prop("Mana", 2) # a component never declared in source
print(world_has(e, Mana)) # 0 — not attached yet
world_attach_dyn(e, Mana)
print(world_has(e, Mana)) # 1
world_set(e, Mana, 0, 30) # field 0 (current)
world_set(e, Mana, 1, 100) # field 1 (max)
print(world_get(e, Mana, 0)) # 30
print(world_get(e, Mana, 1)) # 100
if world_prop_id("Mana") == Mana { print(1) } else { print(0) } # 1 — name resolves
world_attach_dyn(e2, Mana)
world_set(e2, Mana, 0, 7)
print(world_get(e, Mana, 0)) # 30 — per-entity isolation
}
}

22
examples/world_get.ludic Normal file
View file

@ -0,0 +1,22 @@
# world_get.ludic — EV2 reflection: read and write a component by NAME through the
# world table, driven from Ludic (replaces tests/mod_c/world_mod.c). world_prop_id
# / world_field_id resolve names to ids; world_get / world_set / world_has read and
# write by those ids — the same reflection ABI a foreign mod binds, now callable
# from Ludic. Spawns a Unit (Init sets hp=max=50), then reads/tests/writes hp.
# Prints 50 / 1 / 7.
program WorldGet {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max } # -> emits the world table
entry {
spawn Unit { Health { max: 50 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
let e = world_query_next(H, 0)
print(world_get(e, H, hp)) # 50 — read by name
print(world_has(e, H)) # 1 — entity has Health
world_set(e, H, hp, 7) # write by name
print(world_get(e, H, hp)) # 7 — the write stuck
}
}

View file

@ -0,0 +1,17 @@
# world_mixed.ludic — EV2b reflection: get/set use real struct offsets, not a
# field*4 assumption (replaces tests/mod_c/world_mixed.c). Slot's `qty` sits after
# a `ptr` field, so its byte offset is 8, not 4; writing qty=99 by name and reading
# it back proves the world table addresses fields by their true layout. Prints 99.
program WorldMixed {
property Slot { ref: ptr = null, qty: int = 0 }
model Item { Slot }
@Public @OnSpawn(Item) handler Born { } # makes it a modding program
entry {
let e = world_spawn(world_model_id("Item"))
let S = world_prop_id("Slot")
let qty = world_field_id(S, "qty") # field index 1, after the ptr
world_set(e, S, qty, 99)
print(world_get(e, S, qty)) # 99 iff the offset is 8, not 4
}
}

View file

@ -0,0 +1,26 @@
# world_query.ludic — EV2b reflection: iterate the world by property (replaces
# tests/mod_c/world_query.c). world_query_next(prop, from) returns the next live
# entity (>= from) that has the property, or -1 — so a mod walks all bearers of a
# component without knowing the models. Two Units (hp 50, 60) → count 2, sum 110.
program WorldQuery {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }
entry {
spawn Unit { Health { max: 50 } }
spawn Unit { Health { max: 60 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
var count = 0
var sum = 0
var e = world_query_next(H, 0)
while e >= 0 {
count = count + 1
sum = sum + world_get(e, H, hp)
e = world_query_next(H, e + 1)
}
print(count) # 2
print(sum) # 110
}
}

30
examples/world_scan.ludic Normal file
View file

@ -0,0 +1,30 @@
# world_scan.ludic — EV2b reflection: scan the world and identify each entity by
# MODEL, by name (replaces tests/mod_c/world_scan.c). world_count / world_kind /
# world_model_id let a mod walk every entity and pick out a model's instances, then
# read their fields with world_get. Two Units (hp 50, 60) → count 2, sum 110.
program WorldScan {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { Health.hp = Health.max }
entry {
spawn Unit { Health { max: 50 } }
spawn Unit { Health { max: 60 } }
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
let Um = world_model_id("Unit")
let n = world_count()
var count = 0
var sum = 0
var e = 0
while e < n {
if world_kind(e) == Um {
count = count + 1
sum = sum + world_get(e, H, hp)
}
e = e + 1
}
print(count) # 2
print(sum) # 110
}
}

View file

@ -0,0 +1,20 @@
# world_spawn.ludic — EV2b reflection: a mod creates a fresh entity by model id
# (replaces tests/mod_c/world_spawn.c). world_spawn reuses the compiler's own
# spawn lowering (alloc, kind, component defaults, @OnSpawn), so a mod-spawned
# entity is indistinguishable from one born in source. Prints 1 / 42 / 1.
program WorldSpawn {
property Health { hp: int = 0, max: int = 0 }
model Unit { Health }
@Public @OnSpawn(Unit) handler Init { } # world table; leave defaults
entry {
let Um = world_model_id("Unit")
let e = world_spawn(Um) # the mod creates an entity
let H = world_prop_id("Health")
let hp = world_field_id(H, "hp")
print(world_has(e, H)) # 1 — spawn attached Health with defaults
world_set(e, H, hp, 42)
print(world_get(e, H, hp)) # 42
print(world_count()) # 1 — it is really in the world
}
}