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:
parent
96d01e45ab
commit
bca8f126fc
67 changed files with 24066 additions and 9309 deletions
27
examples/cancel.ludic
Normal file
27
examples/cancel.ludic
Normal 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
38
examples/detach.ludic
Normal 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
25
examples/events.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
27
examples/layer_events.ludic
Normal file
27
examples/layer_events.ludic
Normal 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
16
examples/mod_events.ludic
Normal 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
45
examples/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:
|
||||
# ./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
24
examples/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/net_owner.ludic
Normal file
25
examples/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/net_roles.ludic
Normal file
33
examples/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/net_rpc.ludic
Normal file
29
examples/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/net_rt.ludic
Normal file
33
examples/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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
29
examples/net_snapshot.ludic
Normal file
29
examples/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/net_sync.ludic
Normal file
40
examples/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)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
examples/program_events.ludic
Normal file
18
examples/program_events.ludic
Normal 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
32
examples/promote.ludic
Normal 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()
|
||||
}
|
||||
}
|
||||
26
examples/prop_events.ludic
Normal file
26
examples/prop_events.ludic
Normal 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
38
examples/reason.ludic
Normal 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
21
examples/recurse.ludic
Normal 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)
|
||||
}
|
||||
}
|
||||
22
examples/scene_events.ludic
Normal file
22
examples/scene_events.ludic
Normal 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() } }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
21
examples/scoped.ludic
Normal 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
27
examples/world_dyn.ludic
Normal 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
22
examples/world_get.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
17
examples/world_mixed.ludic
Normal file
17
examples/world_mixed.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
26
examples/world_query.ludic
Normal file
26
examples/world_query.ludic
Normal 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
30
examples/world_scan.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
20
examples/world_spawn.ludic
Normal file
20
examples/world_spawn.ludic
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue