{name}
#{sig}'
- '{desc}
{ex}diff --git a/docs/language/annotations/annot-computed.md b/docs/language/annotations/annot-computed.md
index 5bcb92cf..f878da87 100644
--- a/docs/language/annotations/annot-computed.md
+++ b/docs/language/annotations/annot-computed.md
@@ -5,8 +5,30 @@ category: annotations
kind: annotation
tokens: @Computed
sig: @Computed on a property field
-tip: A derived field: an expression expanded inline wherever it is read, never stored.
+tip: A derived field expanded inline wherever it is read, never stored.
order: 1
---
-A derived field: an expression expanded inline wherever it is read, never stored.
+ %(pipenote)s %(feat_intro)s %(sc_intro)s %(start_intro)s %(ed_intro)s %(ed_note)s@Computed marks a property field as derived: instead of occupying a slot in the world, its expression is expanded inline at every site that reads it, so it is recomputed on demand and never takes storage. Use it for values that are a pure function of other fields — a total, a midpoint, a scaled amount — where storing them would risk going stale. Because a computed field has no backing storage it cannot be assigned to, and it is not something you replicate: it costs nothing on the wire and is simply recomputed on each peer. Reach for it when a value should always be consistent with its inputs and you would otherwise have to remember to recompute it by hand.
+
+```ludic
+program DerivedStats {
+ property Health {
+ current: int = 100,
+ maximum: int = 100,
+ @Computed missing: int = maximum - current # never stored; expanded where read
+ }
+ model Player { Health }
+
+ handler ReportDamage phase Update {
+ for (Health) in query [Health] {
+ Health.current = 70
+ print(Health.missing) # 30 — recomputed from current and maximum
+ }
+ }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Health { current: 100, maximum: 100 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-export.md b/docs/language/annotations/annot-export.md
index 79a496ce..5820f023 100644
--- a/docs/language/annotations/annot-export.md
+++ b/docs/language/annotations/annot-export.md
@@ -5,8 +5,24 @@ category: annotations
kind: annotation
tokens: @export
sig: @export fn name(…) -> R
-tip: Expose a function as a C-ABI symbol from a module.
+tip: Expose a function as a C-ABI symbol so a host can call it.
order: 3
---
-Expose a function as a C-ABI symbol from a module.
+@export makes a fn visible outside the module as a plain C-ABI symbol, so a host program or another linked object can call it by name. Without it, functions are internal to the compiled unit; with it, the emitted symbol is externally linkable, which is how Ludic hands entry points to a runtime seam or a foreign caller. It is the outbound counterpart to extern fn, which pulls a foreign symbol in. Keep exported signatures to POD scalars and pointers, since they cross a C boundary where Ludic's richer types do not apply.
+
+```ludic
+program ScoreModule {
+ var running_total: int = 0
+
+ @export fn add_points(amount: int) -> int { # callable from a C-ABI host
+ running_total = running_total + amount
+ return running_total
+ }
+
+ entry {
+ print(add_points(10)) # 10
+ print(add_points(5)) # 15
+ }
+}
+```
diff --git a/docs/language/annotations/annot-handles.md b/docs/language/annotations/annot-handles.md
new file mode 100644
index 00000000..f8e35e27
--- /dev/null
+++ b/docs/language/annotations/annot-handles.md
@@ -0,0 +1,33 @@
+---
+id: annot-handles
+name: @Handles
+category: annotations
+kind: annotation
+tokens: @Handles
+sig: @Handles(Event) handler Name { … }
+tip: Declarative hint naming the event or subsystem a handler is responsible for.
+order: 50
+---
+
+@Handles is a declarative annotation that names the event or subsystem a handler (or a whole program) is responsible for. The parser accepts and records it as metadata for analysis and tooling — it does not by itself wire up dispatch, so it documents intent rather than driving execution. When you want a handler to actually run in response to an event, use @On(Event), which registers the listener; @Handles is the companion label that makes ownership of an event legible across a modular codebase. Keep the named symbol a real event or system so the annotation stays meaningful.
+
+```ludic
+program CombatModule {
+ property Health { current: int = 100 }
+ model Enemy { Health }
+
+ event DamageDealt { amount: int = 0 }
+
+ @Handles(DamageDealt) # documents this handler's responsibility
+ @On(DamageDealt) handler ApplyDamage { # @On does the actual dispatch wiring
+ for (Health) in query [Health, {Enemy}] {
+ Health.current = Health.current - amount
+ }
+ }
+
+ entry {
+ spawn Enemy { Health { current: 100 } }
+ emit DamageDealt(amount: 25)
+ }
+}
+```
diff --git a/docs/language/annotations/annot-on.md b/docs/language/annotations/annot-on.md
index acb4f5a6..976fd9a9 100644
--- a/docs/language/annotations/annot-on.md
+++ b/docs/language/annotations/annot-on.md
@@ -1,12 +1,31 @@
---
id: annot-on
-name: @OnEnable / @OnDisable / @OnDespawn
+name: @On
category: annotations
kind: annotation
-tokens: @OnEnable @OnDisable @OnDespawn
-sig: @OnDisable(Prop) handler … { … }
-tip: Lifecycle hooks that fire when a property/model is toggled or an entity is torn down.
+tokens: @On
+sig: @On(Event) handler Name { … }
+tip: Register a compile-time listener that runs whenever an event is emitted.
order: 2
---
-Lifecycle hooks that fire when a property/model is toggled or an entity is torn down.
+@On(Event) turns a handler into a listener for a named event: whenever any code runs emit Event(...), this handler's body runs with the event's fields bound by name. It is how systems talk to each other without referencing one another directly — the emitter never knows who is listening, so gameplay, UI and mods can all react to the same moment independently. Several handlers may listen to one event; they run in declaration order. On a cancellable event, a listener may cancel to veto it, and the emitter sees that outcome. Related lifecycle hooks such as @OnSpawn, @OnEnable and @OnDespawn are specialized event listeners the compiler wires up for you.
+
+```ludic
+program Events {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Health }
+
+ event Damaged { amount: int = 0 }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Health { current: 100, maximum: 100 } }
+ emit Damaged(amount: 25)
+ }
+
+ @On(Damaged)
+ handler ReportDamage {
+ print(amount)
+ }
+}
+```
diff --git a/docs/language/annotations/annot-onattach.md b/docs/language/annotations/annot-onattach.md
new file mode 100644
index 00000000..48cacd03
--- /dev/null
+++ b/docs/language/annotations/annot-onattach.md
@@ -0,0 +1,33 @@
+---
+id: annot-onattach
+name: @OnAttach
+category: annotations
+kind: annotation
+tokens: @OnAttach
+sig: @OnAttach(Property) handler Name { … }
+tip: Run a handler when a property is structurally attached to a live instance.
+order: 50
+---
+
+@OnAttach(Property) fires when the named property is structurally added to a live model instance with attach P on e — the property's fields are seeded first, then the hook runs in the context of that instance so it can read and adjust them. This is the structural birth of a property, the counterpart to @OnDetach; it differs from @OnEnable, which only un-pauses a property that already exists. Use it to react when a capability appears on an instance at runtime — a shield goes up, a status effect lands. The property name is a checked reference. Add @Public to also emit a prop_<Property>_attach event.
+
+```ludic
+program AttachHook {
+ property Tag { value: int = 0 }
+ property Shield { amount: int = 0 }
+ model Unit { Tag }
+
+ @OnAttach(Shield) handler RaiseShield { # fires when Shield is attached
+ print(Shield.amount + 10) # 15 — reads the seeded amount
+ }
+
+ handler Seed phase Start { spawn Unit { Tag { value: 1 } } }
+
+ handler AddShield phase Render {
+ for (unit) in query [Unit] {
+ attach Shield on self() { amount: 5 } # triggers @OnAttach
+ }
+ quit()
+ }
+}
+```
diff --git a/docs/language/annotations/annot-ondespawn.md b/docs/language/annotations/annot-ondespawn.md
new file mode 100644
index 00000000..4ab01768
--- /dev/null
+++ b/docs/language/annotations/annot-ondespawn.md
@@ -0,0 +1,34 @@
+---
+id: annot-ondespawn
+name: @OnDespawn
+category: annotations
+kind: annotation
+tokens: @OnDespawn
+sig: @OnDespawn(Model, reason: r) handler Name { … }
+tip: Run a handler when a model instance is torn down, optionally knowing why.
+order: 50
+---
+
+@OnDespawn(Model) is the teardown counterpart to @OnSpawn: it fires once for each instance of the named model as it is removed, whether by an in-world despawn or at program shutdown, and its body still reads the instance's outgoing field values before they are gone. With the optional reason: r binding it becomes reason-carrying teardown — r is bound to an EndReason the compiler passes at each teardown site (an in-world despawn passes EndReason.Despawned, program exit passes EndReason.Quit) so one hook can branch on why the instance is ending, the way Unreal's EndPlay(reason) does. Every still-live instance's hook fires at quit, which makes "no silent deaths" real: drop loot on a real death but skip it when the app is simply closing. Add @Public to also emit a model_<Model>_despawn event.
+
+```ludic
+program DespawnHook {
+ property Health { current: int = 0 }
+ property Loot { gold: int = 0 }
+ model Enemy { Health, Loot }
+
+ @OnDespawn(Enemy, reason: teardown_reason) handler DropLoot {
+ match teardown_reason {
+ EndReason.Quit => {} # app closing — do not drop loot
+ _ => { print(Loot.gold) } # died in-world — award the gold
+ }
+ }
+
+ handler SpawnWave phase Start { spawn Enemy { Health { current: 10 }, Loot { gold: 25 } } }
+
+ handler KillOne phase Render {
+ for (Health) in query [Health, {Enemy}] { despawn self() } # prints 25
+ quit()
+ }
+}
+```
diff --git a/docs/language/annotations/annot-ondetach.md b/docs/language/annotations/annot-ondetach.md
new file mode 100644
index 00000000..aca005d0
--- /dev/null
+++ b/docs/language/annotations/annot-ondetach.md
@@ -0,0 +1,36 @@
+---
+id: annot-ondetach
+name: @OnDetach
+category: annotations
+kind: annotation
+tokens: @OnDetach
+sig: @OnDetach(Property) handler Name { … }
+tip: Run a handler when a property is structurally detached from a live instance.
+order: 50
+---
+
+@OnDetach(Property) is the teardown counterpart to @OnAttach: it fires when the named property is structurally removed from a live instance with detach P on e. The hook runs before the property's presence flag clears, so its body can still read the outgoing field values one last time — useful for releasing whatever the property was tracking or logging its final state. It is distinct from @OnDisable, which pauses a property while keeping its data; detach actually destroys the property's presence on the instance. Add @Public to also emit a prop_<Property>_detach event.
+
+```ludic
+program DetachHook {
+ property Tag { value: int = 0 }
+ property Shield { amount: int = 0 }
+ model Unit { Tag }
+
+ @OnDetach(Shield) handler DropShield { # fires as Shield is removed
+ print(Shield.amount + 20) # 25 — reads the outgoing amount
+ }
+
+ handler Seed phase Start {
+ spawn Unit { Tag { value: 1 } }
+ for (unit) in query [Unit] { attach Shield on self() { amount: 5 } }
+ }
+
+ handler RemoveShield phase Render {
+ for (shield) in query [Shield] {
+ detach Shield on self() # triggers @OnDetach
+ }
+ quit()
+ }
+}
+```
diff --git a/docs/language/annotations/annot-ondisable.md b/docs/language/annotations/annot-ondisable.md
new file mode 100644
index 00000000..f763ba6b
--- /dev/null
+++ b/docs/language/annotations/annot-ondisable.md
@@ -0,0 +1,33 @@
+---
+id: annot-ondisable
+name: @OnDisable
+category: annotations
+kind: annotation
+tokens: @OnDisable
+sig: @OnDisable(Property) handler Name { … }
+tip: Run a handler when a property is paused (disabled) on an instance.
+order: 50
+---
+
+@OnDisable(Property) fires when a present property is paused with disable P on e. Disabling keeps the property's data intact and simply stops it from participating — the logical "off" switch — so this hook is the moment a capability goes dormant, not the moment it is destroyed (that is @OnDetach). The body runs in the instance's context and can read the fields before they go quiet, which is handy for cleaning up an effect the property was driving. It pairs with @OnEnable for capabilities that toggle on and off repeatedly. Add @Public to also emit a prop_<Property>_disable event.
+
+```ludic
+program DisableHook {
+ property Tag { value: int = 0 }
+ property Shield { amount: int = 0 }
+ model Unit { Tag, Shield }
+
+ @OnDisable(Shield) handler ShieldDown { # fires when Shield is disabled
+ print(Shield.amount) # 5 — data is retained, just paused
+ }
+
+ handler Seed phase Start { spawn Unit { Tag { value: 1 }, Shield { amount: 5 } } }
+
+ handler Deactivate phase Render {
+ for (unit) in query [Unit] {
+ disable Shield on self() # triggers @OnDisable
+ }
+ quit()
+ }
+}
+```
diff --git a/docs/language/annotations/annot-onenable.md b/docs/language/annotations/annot-onenable.md
new file mode 100644
index 00000000..f5fe8ba6
--- /dev/null
+++ b/docs/language/annotations/annot-onenable.md
@@ -0,0 +1,34 @@
+---
+id: annot-onenable
+name: @OnEnable
+category: annotations
+kind: annotation
+tokens: @OnEnable
+sig: @OnEnable(Property) handler Name { … }
+tip: Run a handler when a paused property is re-enabled on an instance.
+order: 50
+---
+
+@OnEnable(Property) fires when a property that is present but disabled is turned back on with enable P on e. Unlike @OnAttach, the property's data was never destroyed — enable/disable only pause and resume a property that already exists, keeping its fields intact — so this hook is about a capability becoming active again rather than being created. The body runs in the instance's context and can read the retained field values. Use the enable/disable pair for a capability that toggles repeatedly (a power-up, a stun) where re-seeding on every toggle would be wrong. Add @Public to also emit a prop_<Property>_enable event.
+
+```ludic
+program EnableHook {
+ property Tag { value: int = 0 }
+ property Shield { amount: int = 0 }
+ model Unit { Tag, Shield }
+
+ @OnEnable(Shield) handler ShieldBackOnline { # fires when Shield is re-enabled
+ print(Shield.amount) # 5 — the retained amount, not re-seeded
+ }
+
+ handler Seed phase Start {
+ spawn Unit { Tag { value: 1 }, Shield { amount: 5 } }
+ for (unit) in query [Unit] { disable Shield on self() }
+ }
+
+ handler Reactivate phase Render {
+ for (unit) in query [Unit] { enable Shield on self() } # triggers @OnEnable
+ quit()
+ }
+}
+```
diff --git a/docs/language/annotations/annot-onquit.md b/docs/language/annotations/annot-onquit.md
new file mode 100644
index 00000000..c701c68d
--- /dev/null
+++ b/docs/language/annotations/annot-onquit.md
@@ -0,0 +1,27 @@
+---
+id: annot-onquit
+name: @OnQuit
+category: annotations
+kind: annotation
+tokens: @OnQuit
+sig: @OnQuit handler Name { … }
+tip: Run a handler once at shutdown, after the last frame.
+order: 50
+---
+
+@OnQuit pins a handler to the program's shutdown moment: it runs exactly once as the program exits, after the final frame. Use it for teardown that must happen once — flushing a save, printing a final tally, releasing a host resource. In the lifecycle timeline it runs after every still-live instance's @OnDespawn hook has fired, so the world has already been torn down by the time @OnQuit sees it. It is the boot-time counterpart of @OnStart; add @Public to also emit a program_quit event other modules can hook.
+
+```ludic
+program QuitHook {
+ property Score { total: int = 0 }
+ model Scoreboard { Score }
+
+ @OnQuit handler ReportFinalScore { # runs once at shutdown
+ for (Score) in query [Score] { print(Score.total) }
+ }
+
+ handler Seed phase Start { spawn Scoreboard { Score { total: 42 } } }
+
+ handler Finish phase Render { quit() } # triggers shutdown -> prints 42
+}
+```
diff --git a/docs/language/annotations/annot-onspawn.md b/docs/language/annotations/annot-onspawn.md
new file mode 100644
index 00000000..25f518d8
--- /dev/null
+++ b/docs/language/annotations/annot-onspawn.md
@@ -0,0 +1,28 @@
+---
+id: annot-onspawn
+name: @OnSpawn
+category: annotations
+kind: annotation
+tokens: @OnSpawn
+sig: @OnSpawn(Model) handler Name { … }
+tip: Run a handler once each time an instance of a model is spawned.
+order: 50
+---
+
+@OnSpawn(Model) turns a handler into a birth hook: it fires once for each newly spawned instance of the named model, right after its properties are seeded. The body runs in the context of that fresh instance, so it can read and adjust the instance's fields by property name to finish initialization — seed derived state, register the instance, play a spawn effect. The model name is a checked reference, so a typo is a compile error rather than a silently dead hook. Its teardown counterpart is @OnDespawn; add @Public to also emit a model_<Model>_spawn event other modules can listen for.
+
+```ludic
+program SpawnHook {
+ property Health { current: int = 0, maximum: int = 100 }
+ model Enemy { Health }
+
+ @OnSpawn(Enemy) handler InitEnemy { # fires per new Enemy instance
+ Health.current = Health.maximum # start every enemy at full health
+ print(Health.current) # 100
+ }
+
+ handler SpawnWave phase Start {
+ spawn Enemy { Health { current: 0, maximum: 100 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-onstart.md b/docs/language/annotations/annot-onstart.md
new file mode 100644
index 00000000..2efa81b7
--- /dev/null
+++ b/docs/language/annotations/annot-onstart.md
@@ -0,0 +1,28 @@
+---
+id: annot-onstart
+name: @OnStart
+category: annotations
+kind: annotation
+tokens: @OnStart
+sig: @OnStart handler Name { … }
+tip: Run a handler once at boot instead of assigning it a frame phase.
+order: 50
+---
+
+@OnStart pins a handler to the program's boot moment: it runs exactly once when the program starts, before the per-frame phases begin, instead of being placed in a recurring phase. Use it for one-time setup — spawning the initial world, seeding program vars, loading a scene. It overrides the handler's phase, so you write @OnStart handler Name { … } rather than handler Name phase Start and get the same once-at-boot placement through the annotation channel. Its shutdown counterpart is @OnQuit; add @Public to also emit a program_start event other modules can hook.
+
+```ludic
+program BootHook {
+ property Health { current: int = 0, maximum: int = 100 }
+ model Player { Health }
+
+ var elapsed_frames: int = 0
+
+ @OnStart handler CreateWorld { # runs once at boot
+ spawn Player { Health { current: 100, maximum: 100 } }
+ elapsed_frames = 0
+ }
+
+ handler CountFrames phase Update { elapsed_frames = elapsed_frames + 1 }
+}
+```
diff --git a/docs/language/annotations/annot-owned.md b/docs/language/annotations/annot-owned.md
new file mode 100644
index 00000000..85e52b16
--- /dev/null
+++ b/docs/language/annotations/annot-owned.md
@@ -0,0 +1,29 @@
+---
+id: annot-owned
+name: @Owned
+category: annotations
+kind: annotation
+tokens: @Owned
+sig: @Owned model Name { … }
+tip: Give a model a network owner slot so its instances can be assigned to a peer.
+order: 50
+---
+
+@Owned declares that every instance of a model carries a network owner — it adds an owner slot to the model (the runtime's @L_owner array), which the builtins owner, set_owner, and is_owner read and assign. A fresh instance starts unowned (-1) until the authority assigns it. Ownership is what gates who may write @Sync(to: owner) fields and who runs @Predicted control handlers, and it is stored in the world snapshot so it round-trips through replication and rollback. Mark the models that represent a player's avatar or units; leave shared scenery unowned.
+
+```ludic
+program OwnedModel {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position } # each Player instance has an owner slot
+
+ entry {
+ spawn Player { Position { column: 5, row: 6 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ print(owner(player_id)) # -1 — unowned until assigned
+ set_owner(player_id, 0)
+ print(is_owner(player_id)) # 1 — the local peer now owns it
+ }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-predicted.md b/docs/language/annotations/annot-predicted.md
new file mode 100644
index 00000000..4c241c95
--- /dev/null
+++ b/docs/language/annotations/annot-predicted.md
@@ -0,0 +1,30 @@
+---
+id: annot-predicted
+name: @Predicted
+category: annotations
+kind: annotation
+tokens: @Predicted
+sig: @Predicted handler Name { … }
+tip: Run a control handler on the owning client speculatively and on the server authoritatively.
+order: 50
+---
+
+@Predicted marks a control handler that runs in two places: speculatively on the client that owns the affected instance, so local input feels instant, and authoritatively on the server, whose result reconciles the client if the two diverge. It is the responsive-control role from Quake-style client-prediction, named for the netcode behavior (owner-predicts plus server-authoritative plus reconcile) rather than the machine, and it matches Unity's GhostMode.Predicted so the concept transfers. Prediction is explicit — Ludic never silently predicts — and it only applies to instances of an @Owned model, since the dispatch reads is_owner to decide whether the local client should run it. Use it for the owning player's movement and actions; leave authority-only rules on @Server.
+
+```ludic
+program PredictedMovement {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ @Predicted handler MovePlayer phase Input { # owner predicts, server reconciles
+ let pressed = Input.key()
+ for (Position) in query [Position, {Player}] {
+ if pressed == 'd' { Position.column = Position.column + 1 }
+ }
+ }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 0, row: 0 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-public.md b/docs/language/annotations/annot-public.md
new file mode 100644
index 00000000..2217512b
--- /dev/null
+++ b/docs/language/annotations/annot-public.md
@@ -0,0 +1,31 @@
+---
+id: annot-public
+name: @Public
+category: annotations
+kind: annotation
+tokens: @Public
+sig: @Public @OnSpawn(Model) handler … { … }
+tip: Promote a lifecycle hook to a public event other modules can listen for.
+order: 50
+---
+
+@Public stacks in front of a lifecycle hook to also emit a well-named event at the same moment the hook fires, so other modules (or mods) can react without touching the original code. It turns a private hook into a public seam: @Public @OnSpawn(Model) emits model_<Model>_spawn, @OnDespawn emits model_<Model>_despawn, the property hooks emit prop_<Property>_attach/_detach/_enable/_disable, and @OnStart/@OnQuit emit program_start/program_quit. This is the same lowering the event system uses, so anyone can @On the generated event to extend behavior at that lifecycle point. Add it when a hook marks an extension point you want to keep open across a modular codebase.
+
+```ludic
+program PublicSpawn {
+ property Health { current: int = 0, maximum: int = 100 }
+ model Enemy { Health }
+
+ @Public @OnSpawn(Enemy) handler InitEnemy { # also emits model_Enemy_spawn
+ Health.current = Health.maximum
+ }
+
+ @On(model_Enemy_spawn) handler AnnounceEnemy { # another module hooks the public event
+ print(1) # 1 — reacts to every Enemy spawn
+ }
+
+ handler SpawnWave phase Start {
+ spawn Enemy { Health { current: 0, maximum: 100 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-queries.md b/docs/language/annotations/annot-queries.md
index 1ef62402..4ac98fd1 100644
--- a/docs/language/annotations/annot-queries.md
+++ b/docs/language/annotations/annot-queries.md
@@ -4,9 +4,27 @@ name: @Queries
category: annotations
kind: annotation
tokens: @Queries
-sig: @Queries(these: [Pos, Vel])
-tip: Declare the properties a handler touches, binding their fields by name in the body.
+sig: @Queries(these: [Position, Velocity])
+tip: Declare the properties a handler operates on, binding their fields by name in the body.
order: 0
---
-Declare the properties a handler touches, binding their fields by name in the body.
+@Queries lets a handler declare the properties it operates on up front, and the compiler wraps the whole body in a query loop over the matching model instances. The body then runs once per match with each listed property bound by name — you write Position.column directly instead of opening a for (…) in query […] yourself — and self() gives the current instance. It takes the same shape as a manual query: these: [ … ] lists the required properties (each may carry a filter like Health{current <= 0}), and an optional on: Model restricts the match to instances of one model. Reach for it when a handler's entire job is "for every matching instance, do this"; drop to an explicit query when you need finer control inside the loop.
+
+```ludic
+program AdvanceMovement {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Player { Position, Velocity }
+
+ @Queries(these: [Position, Velocity], on: Player) # body runs once per matching Player
+ handler AdvancePositions phase FixedUpdate {
+ Position.column = Position.column + Velocity.delta_x
+ Position.row = Position.row + Velocity.delta_y
+ }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 0, row: 0 }, Velocity { delta_x: 1, delta_y: 0 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-reads.md b/docs/language/annotations/annot-reads.md
new file mode 100644
index 00000000..91a865bf
--- /dev/null
+++ b/docs/language/annotations/annot-reads.md
@@ -0,0 +1,31 @@
+---
+id: annot-reads
+name: @Reads
+category: annotations
+kind: annotation
+tokens: @Reads
+sig: @Reads(Property)
+tip: Declare that a handler reads a property — an analysis and scheduling hint.
+order: 50
+---
+
+@Reads(Property) declares that a handler reads the named property. It is an analysis hint: it documents a handler's data dependencies so tooling and scheduling can reason about which handlers touch which state — for example, to tell whether two handlers can run without conflict. It does not itself bind fields the way @Queries does, nor does it grant access; it annotates intent alongside the handler's actual query. Pair it with @Writes to spell out the full read/write footprint of a handler. Keep the named property real so the declared footprint matches what the body does.
+
+```ludic
+program ReadFootprint {
+ property Health { current: int = 100 }
+ property Score { total: int = 0 }
+ model Player { Health, Score }
+
+ @Reads(Health) # declares the read dependency
+ handler ReportLowHealth phase Update {
+ for (Health) in query [Health, {Player}] {
+ if Health.current < 25 { print(Health.current) }
+ }
+ }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Health { current: 20 }, Score { total: 0 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-server.md b/docs/language/annotations/annot-server.md
new file mode 100644
index 00000000..301ea6c5
--- /dev/null
+++ b/docs/language/annotations/annot-server.md
@@ -0,0 +1,31 @@
+---
+id: annot-server
+name: @Server
+category: annotations
+kind: annotation
+tokens: @Server
+sig: @Server handler Name { … }
+tip: Run a handler only on the authority; clients receive the result via @Sync.
+order: 50
+---
+
+@Server marks a handler as server-authoritative: it runs only on the peer acting as the authority, and clients receive its effects through ordinary @Sync replication rather than by running the handler themselves. This is the declarative alternative to sprinkling is_server() branches through gameplay code — the role is a property of the handler, so every line's placement stays legible. Unmarked handlers are the shared, deterministic simulation that runs everywhere; use @Server for decisions that must have a single source of truth, like awarding score, resolving damage, or spawning authoritative entities. Offline the role register defaults to server, so the guard collapses to "run here" and a single-player build is unchanged.
+
+```ludic
+program AuthoritativeScore {
+ property Score { total: int = 0 }
+ model Scoreboard { Score }
+
+ handler Tick phase Update { # unmarked -> runs on every peer
+ for (Score) in query [Score] { Score.total = Score.total + 1 }
+ }
+
+ @Server handler AwardBonus phase Update { # authority only
+ for (Score) in query [Score] { Score.total = Score.total + 100 }
+ }
+
+ handler SpawnScoreboard phase Start {
+ spawn Scoreboard { Score { total: 0 } }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-sync.md b/docs/language/annotations/annot-sync.md
index a01629bb..ff16d21c 100644
--- a/docs/language/annotations/annot-sync.md
+++ b/docs/language/annotations/annot-sync.md
@@ -1,12 +1,29 @@
---
id: annot-sync
-name: @Sync / @Owned
+name: @Sync
category: annotations
kind: annotation
-tokens: @Sync @Owned
+tokens: @Sync
sig: @Sync property … / @Owned
-tip: Mark a property's fields as replicated, and entities as owned, for networking.
+tip: Mark fields as replicated and models as owned so the compiler generates networking.
order: 4
---
-Mark a property's fields as replicated, and entities as owned, for networking.
+These are the two declarative switches at the top of Ludic's networking: @Sync says what replicates and @Owned says who owns an instance. Replication is opt-in at the field level and decided per model use-site — a field crosses the wire only when it is both replicable (marked @Sync, either on the field or via @Sync property P which marks every field of P) and participating (the model marks the component @Sync). So the same property can replicate in one model and not another, and there is no @NoSync because the surface is purely additive. From these marks the compiler generates the per-model serialize/apply codecs; @Sync on a non-POD-scalar field (like a ptr) is a compile error, since a machine-local pointer cannot cross the wire. @Owned adds the owner slot the ownership builtins and @Predicted read. See the dedicated @Owned page for ownership details.
+
+```ludic
+program SyncedWorld {
+ @Sync property Position { column: int = 0, row: int = 0 } # every field replicable
+ property Health { @Sync current: int = 0, maximum: int = 0 } # only current replicable
+
+ @Owned model Player { @Sync Position, @Sync Health } # participates -> column,row,current
+ model Scenery { Position } # not @Sync here -> never replicates
+
+ entry {
+ spawn Player { Position { column: 3, row: 4 }, Health { current: 50, maximum: 100 } }
+ for (Position, Health) in query [Position, Health, {Player}] {
+ print(sync_size(self())) # 12 = Position(8) + Health.current(4)
+ }
+ }
+}
+```
diff --git a/docs/language/annotations/annot-toclients.md b/docs/language/annotations/annot-toclients.md
new file mode 100644
index 00000000..c8ec669d
--- /dev/null
+++ b/docs/language/annotations/annot-toclients.md
@@ -0,0 +1,32 @@
+---
+id: annot-toclients
+name: @ToClients
+category: annotations
+kind: annotation
+tokens: @ToClients
+sig: @ToClients event Name { … }
+tip: A remote event broadcast from the server to clients — a server→clients notification.
+order: 50
+---
+
+@ToClients marks an event as a directional remote event that travels from the authority down to clients — a broadcast, again the ordinary event bus with a direction flag. When the server emits it, the POD payload is serialized as an event id plus its packed fields and sent through the transport seam to the clients; each client's pump re-emits it into normal @On dispatch, so an @On(Name) handler on every client reacts. Use it for authoritative notifications a client should present but not decide — an explosion happened, a round ended, a pickup was granted. Its mirror is @ToServer, which carries client requests the other way.
+
+```ludic
+program ExplosionBroadcast {
+ property EffectLog { count: int = 0 }
+ model Client { EffectLog }
+
+ @ToClients event Boom { column: int = 0, row: int = 0 } # server -> clients broadcast
+
+ @On(Boom) handler PlayExplosion { # every client reacts
+ for (EffectLog) in query [EffectLog, {Client}] {
+ EffectLog.count = EffectLog.count + 1
+ }
+ }
+
+ entry {
+ spawn Client { EffectLog { count: 0 } }
+ emit Boom(column: 8, row: 3) # serialized to clients, re-emitted on arrival
+ }
+}
+```
diff --git a/docs/language/annotations/annot-toserver.md b/docs/language/annotations/annot-toserver.md
new file mode 100644
index 00000000..19560a61
--- /dev/null
+++ b/docs/language/annotations/annot-toserver.md
@@ -0,0 +1,32 @@
+---
+id: annot-toserver
+name: @ToServer
+category: annotations
+kind: annotation
+tokens: @ToServer
+sig: @ToServer event Name { … }
+tip: A remote event sent from a client to the server — a client→server request.
+order: 50
+---
+
+@ToServer marks an event as a directional remote event that travels from a client up to the authority — an RPC, expressed as the ordinary event bus with a direction flag rather than a new concept. At an emit site the event's POD payload is serialized as an event id followed by its packed fields and sent through the transport seam toward the server; it does not run locally. On the far side, draining the transport (the runtime's pump) re-emits it into the normal @On dispatch, so a @Server @On(Name) handler picks it up. Use it for player intent — a move request, a fire command — that the authority must validate before it affects the world. Its mirror is @ToClients, which broadcasts the other way.
+
+```ludic
+program FireRequest {
+ property AmmoLog { shots_fired: int = 0 }
+ model Turret { AmmoLog }
+
+ @ToServer event Fire { direction: int = 0 } # client -> server request
+
+ @Server @On(Fire) handler HandleFire { # authority validates and applies
+ for (AmmoLog) in query [AmmoLog, {Turret}] {
+ AmmoLog.shots_fired = AmmoLog.shots_fired + direction
+ }
+ }
+
+ entry {
+ spawn Turret { AmmoLog { shots_fired: 0 } }
+ emit Fire(direction: 1) # serialized onto the wire, not run locally
+ }
+}
+```
diff --git a/docs/language/annotations/annot-writes.md b/docs/language/annotations/annot-writes.md
new file mode 100644
index 00000000..d2e30f57
--- /dev/null
+++ b/docs/language/annotations/annot-writes.md
@@ -0,0 +1,30 @@
+---
+id: annot-writes
+name: @Writes
+category: annotations
+kind: annotation
+tokens: @Writes
+sig: @Writes(Property)
+tip: Declare that a handler writes a property — an analysis and scheduling hint.
+order: 50
+---
+
+@Writes(Property) declares that a handler writes the named property. Like @Reads it is an analysis hint that documents a handler's data footprint so tooling and scheduling can reason about conflicts — two handlers that write the same property, or one that writes what another reads, cannot be reordered freely. It does not bind fields or grant access on its own; the handler still queries and mutates the property in its body as usual. Declaring reads and writes together makes a handler's effect on the world legible at a glance, which matters most in a large, modular codebase. Keep the named property accurate so the declared footprint matches the code.
+
+```ludic
+program WriteFootprint {
+ property Health { current: int = 100 }
+ model Enemy { Health }
+
+ @Reads(Health) @Writes(Health) # full read/write footprint
+ handler ApplyPoison phase Update {
+ for (Health) in query [Health, {Enemy}] {
+ Health.current = Health.current - 1
+ }
+ }
+
+ handler SpawnEnemy phase Start {
+ spawn Enemy { Health { current: 100 } }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-abs.md b/docs/language/builtins/fn-abs.md
index e5907fd9..91d6eecc 100644
--- a/docs/language/builtins/fn-abs.md
+++ b/docs/language/builtins/fn-abs.md
@@ -5,8 +5,34 @@ category: builtins
kind: builtin
tokens: abs
sig: abs(a) -> int
-tip: Absolute value.
+tip: The absolute value of an integer (its magnitude, never negative).
order: 8
---
-Absolute value.
+Returns the magnitude of an integer, dropping its sign — so abs(-7) and abs(7) both give 7. It is handy for distance-style checks where direction does not matter, such as measuring how far two grid cells are apart along one axis before deciding whether something is "close enough". Combine two axis distances to build a simple proximity test. It takes a single integer and returns an integer.
+
+Parameters:
+- `a` — the integer whose magnitude you want
+
+```ludic
+program Proximity {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+ model Enemy { Position }
+
+ handler SpawnActors phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ spawn Enemy { Position { column: 8, row: 6 } }
+ }
+
+ handler CheckAdjacency phase Update {
+ for (PlayerPosition) in query [Position, {Player}] {
+ for (EnemyPosition) in query [Position, {Enemy}] {
+ let column_gap = abs(PlayerPosition.column - EnemyPosition.column)
+ let row_gap = abs(PlayerPosition.row - EnemyPosition.row)
+ if column_gap + row_gap <= 1 { print(1) }
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-arg.md b/docs/language/builtins/fn-arg.md
index a4ab5252..62a973a2 100644
--- a/docs/language/builtins/fn-arg.md
+++ b/docs/language/builtins/fn-arg.md
@@ -1,12 +1,27 @@
---
id: fn-arg
-name: arg_count / arg
+name: arg
category: builtins
kind: builtin
-tokens: arg_count arg
-sig: arg_count() -> int arg(i) -> str
-tip: The command line; argv[0] is included.
-order: 20
+tokens: arg
+sig: arg(i) -> str
+tip: The i-th command-line argument as a string.
+order: 50
---
-Read the process command line: arg_count() is the number of arguments (including the program name at index 0) and arg(i) returns the ith as a string.
+Returns the command-line argument at index i as a string. Index 0 is conventionally the program's own name or path, so the first user-supplied argument is at index 1. Use it together with arg_count to write configurable command-line tools in Ludic — reading an input filename, a mode flag, or a numeric option. Guard the index against arg_count() before reading so you never ask for an argument that was not passed.
+
+Parameters:
+- `i` — the argument index (`0` is the program name)
+
+```ludic
+program EchoFirstArg {
+ handler ShowArg phase Start {
+ if arg_count() > 1 {
+ print(arg(1))
+ } else {
+ print("no argument")
+ }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-arg_count.md b/docs/language/builtins/fn-arg_count.md
new file mode 100644
index 00000000..a9d0221f
--- /dev/null
+++ b/docs/language/builtins/fn-arg_count.md
@@ -0,0 +1,23 @@
+---
+id: fn-arg_count
+name: arg_count
+category: builtins
+kind: builtin
+tokens: arg_count
+sig: arg_count() -> int
+tip: The number of command-line arguments, counting the program name.
+order: 50
+---
+
+Returns how many command-line arguments the program received, including the program name at index 0. So a program run with no user arguments reports 1, and each added argument raises the count by one. Use it to validate that the required arguments were supplied and to bound the index you pass to arg. It takes no arguments and is the natural companion to arg when building command-line tools.
+
+```ludic
+program PrintAllArgs {
+ handler ListArgs phase Start {
+ print(arg_count())
+ for index in 0 .. arg_count() {
+ print(arg(index))
+ }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-bytes.md b/docs/language/builtins/fn-bytes.md
index 97f2fb92..43525e25 100644
--- a/docs/language/builtins/fn-bytes.md
+++ b/docs/language/builtins/fn-bytes.md
@@ -1,12 +1,29 @@
---
id: fn-bytes
-name: bytes / words
+name: bytes
category: builtins
kind: builtin
-tokens: bytes words
-sig: bytes(n) -> ptr / words(n) -> words
-tip: Allocate a raw buffer of n bytes / n 32-bit words.
-order: 11
+tokens: bytes
+sig: bytes(n) -> ptr
+tip: Allocate a raw buffer of n bytes and return a pointer to it.
+order: 50
---
-Allocate a raw buffer of n bytes / n 32-bit words.
+Allocates a raw, byte-addressable buffer of n bytes and returns a pointer to its start. It is a low-level primitive used for I/O and networking scratch space — for example a buffer to serialize the world into, or to receive bytes from a socket. Size it to what you need; a common pattern is bytes(world_size()) so the buffer is exactly large enough for a full world snapshot. For a buffer of 32-bit words rather than individual bytes, use words instead.
+
+Parameters:
+- `n` — the number of bytes to allocate
+
+```ludic
+program SnapshotBuffer {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Snapshot phase Start {
+ spawn Player { Position { column: 3, row: 4 } }
+ let buffer = bytes(world_size())
+ let written = world_save(buffer)
+ print(written)
+ }
+}
+```
diff --git a/docs/language/builtins/fn-clamp.md b/docs/language/builtins/fn-clamp.md
index 69a0ec43..159f102c 100644
--- a/docs/language/builtins/fn-clamp.md
+++ b/docs/language/builtins/fn-clamp.md
@@ -5,8 +5,50 @@ category: builtins
kind: builtin
tokens: clamp
sig: clamp(v, lo, hi) -> int
-tip: Constrain v to [lo, hi].
+tip: Constrain a value to the inclusive range [lo, hi].
order: 9
---
-Constrain v to [lo, hi].
+Constrains a value to a range: it returns lo if v is below the range, hi if it is above, and v unchanged when it already lies within [lo, hi]. The classic use is keeping a moving object on screen or inside a grid, so its position can never run past the walls no matter how fast it moves. It saves you writing a pair of if checks by hand. Make sure lo is not greater than hi.
+
+Parameters:
+- `v` — the value to constrain
+- `lo` — the lowest allowed value (inclusive)
+- `hi` — the highest allowed value (inclusive)
+
+```ludic
+program ClampToGrid {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const GRID_WIDTH: int = 20
+ const GRID_HEIGHT: int = 15
+ const TILE_SIZE: int = 16
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ }
+
+ handler ReadInput phase Input {
+ let pressed = Input.key()
+ for (Position) in query [Position, {Player}] {
+ if pressed == 'd' { Position.column = Position.column + 1 }
+ if pressed == 'a' { Position.column = Position.column - 1 }
+ Position.column = clamp(Position.column, 0, GRID_WIDTH - 1)
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-exit.md b/docs/language/builtins/fn-exit.md
index e69f486b..8ad3657f 100644
--- a/docs/language/builtins/fn-exit.md
+++ b/docs/language/builtins/fn-exit.md
@@ -5,8 +5,24 @@ category: builtins
kind: builtin
tokens: exit
sig: exit(code)
-tip: Exit the process immediately with a status code.
+tip: Terminate the process immediately with a status code.
order: 21
---
-Terminate the process now with the given status code. Unlike quit(), which ends the game loop cleanly after the current frame, exit stops the program at once.
+Terminates the process at once with the given status code, without waiting for the current frame to finish. This is a harder stop than quit, which ends the game loop cleanly after the frame completes; use exit for command-line tools and tests that need to signal success or failure to the shell, where 0 conventionally means success and any non-zero value means an error. Because it stops immediately, drawing queued for the current frame is not presented.
+
+Parameters:
+- `code` — the process exit status (`0` for success, non-zero for failure)
+
+```ludic
+program RequireArgument {
+ handler CheckArgs phase Start {
+ if arg_count() < 2 {
+ print(0)
+ exit(1)
+ }
+ print(1)
+ exit(0)
+ }
+}
+```
diff --git a/docs/language/builtins/fn-file.md b/docs/language/builtins/fn-file.md
deleted file mode 100644
index 603545cd..00000000
--- a/docs/language/builtins/fn-file.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-id: fn-file
-name: file_write / file_stdout / file_stderr
-category: builtins
-kind: builtin
-tokens: file_write file_stdout file_stderr
-sig: file_stdout() -> ptr file_stderr() -> ptr file_write(handle, buf, len)
-tip: Low-level output handles and raw writing.
-order: 25
----
-
-file_stdout() and file_stderr() return the standard stream handles; file_write(handle, buf, len) writes len raw bytes to one. Most code uses print instead — these are the primitive underneath.
diff --git a/docs/language/builtins/fn-file_stderr.md b/docs/language/builtins/fn-file_stderr.md
new file mode 100644
index 00000000..9ed4f1c6
--- /dev/null
+++ b/docs/language/builtins/fn-file_stderr.md
@@ -0,0 +1,26 @@
+---
+id: fn-file_stderr
+name: file_stderr
+category: builtins
+kind: builtin
+tokens: file_stderr
+sig: file_stderr() -> ptr
+tip: The standard-error stream handle for use with file_write.
+order: 50
+---
+
+Returns the handle for the standard-error stream, which you pass to file_write to emit raw bytes. Writing diagnostics and error messages here keeps them separate from normal program output on standard out, so a tool's real results are not mixed with its warnings. It takes no arguments and always refers to the process's standard error. Use it for the error side of command-line tools written in Ludic.
+
+```ludic
+program WarnToStderr {
+ handler CheckArgs phase Start {
+ if arg_count() < 2 {
+ let err = file_stderr()
+ let message = "missing argument\n"
+ file_write(err, message, len(message))
+ exit(1)
+ }
+ print("ok")
+ }
+}
+```
diff --git a/docs/language/builtins/fn-file_stdout.md b/docs/language/builtins/fn-file_stdout.md
new file mode 100644
index 00000000..043e3985
--- /dev/null
+++ b/docs/language/builtins/fn-file_stdout.md
@@ -0,0 +1,22 @@
+---
+id: fn-file_stdout
+name: file_stdout
+category: builtins
+kind: builtin
+tokens: file_stdout
+sig: file_stdout() -> ptr
+tip: The standard-output stream handle for use with file_write.
+order: 50
+---
+
+Returns the handle for the standard-output stream, which you pass to file_write to emit raw bytes. Unlike print, which always appends a newline and handles conversion, writing through this handle gives you exact control over what bytes go out and when — useful for tooling and code generators that must emit precise text. It takes no arguments and always refers to the process's standard output. Pair it with file_stderr when you want to separate normal output from diagnostics.
+
+```ludic
+program WriteLine {
+ handler Emit phase Start {
+ let out = file_stdout()
+ let message = "built\n"
+ file_write(out, message, len(message))
+ }
+}
+```
diff --git a/docs/language/builtins/fn-file_write.md b/docs/language/builtins/fn-file_write.md
new file mode 100644
index 00000000..46b3cd21
--- /dev/null
+++ b/docs/language/builtins/fn-file_write.md
@@ -0,0 +1,29 @@
+---
+id: fn-file_write
+name: file_write
+category: builtins
+kind: builtin
+tokens: file_write
+sig: file_write(handle, buf, len)
+tip: Write a run of raw bytes to a stream handle.
+order: 50
+---
+
+Writes len bytes from buf to the given stream handle. Get the handle from file_stdout or file_stderr; the buffer is typically a string (whose length you pass with len(buf)) or a raw buffer from bytes. This is the precise, no-frills output primitive that underpins Ludic's own tooling and code generators, where exact bytes matter and the automatic newline of print would get in the way. Pass a byte count that does not exceed the buffer's size.
+
+Parameters:
+- `handle` — a stream handle from `file_stdout` or `file_stderr`
+- `buf` — the bytes to write (a string or a raw buffer)
+- `len` — how many bytes to write
+
+```ludic
+program WriteTwice {
+ handler Emit phase Start {
+ let out = file_stdout()
+ let heading = "SCORE: "
+ let value = "250\n"
+ file_write(out, heading, len(heading))
+ file_write(out, value, len(value))
+ }
+}
+```
diff --git a/docs/language/builtins/fn-flr.md b/docs/language/builtins/fn-flr.md
new file mode 100644
index 00000000..075df59e
--- /dev/null
+++ b/docs/language/builtins/fn-flr.md
@@ -0,0 +1,29 @@
+---
+id: fn-flr
+name: flr
+category: builtins
+kind: builtin
+tokens: flr
+sig: flr(x) -> int
+tip: Floor a fixed-point value down to the nearest integer.
+order: 50
+---
+
+Converts a fixed (Q16.16) value back to an int by discarding the fractional part, rounding toward negative infinity. It is the counterpart to fx: you accumulate motion in fixed-point for sub-pixel smoothness, then flr the result to get the whole-pixel column or row to draw at. Because it floors rather than rounds, flr(fx(3) / fx(2)) is 1, not 2. Use it wherever a fixed value must become an integer coordinate, count, or index.
+
+Parameters:
+- `x` — the fixed-point value to floor
+
+```ludic
+program FixedToPixels {
+ const TILE_SIZE: int = 16
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ let smooth_column = fx(5) + fx(1) / fx(2)
+ let pixel_x = flr(smooth_column) * TILE_SIZE
+ Screen.fill_rectangle(x: pixel_x, y: 32, width: TILE_SIZE, height: TILE_SIZE, color: Color.LimeGreen)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-font_load.md b/docs/language/builtins/fn-font_load.md
deleted file mode 100644
index 21b8833f..00000000
--- a/docs/language/builtins/fn-font_load.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-id: fn-font_load
-name: font_load
-category: builtins
-kind: builtin
-tokens: font_load
-sig: font_load(path) -> int
-tip: Load a TrueType font and return a handle for the UI.
-order: 12
----
-
-Load a TrueType font and return a handle for the UI.
diff --git a/docs/language/builtins/fn-fx.md b/docs/language/builtins/fn-fx.md
index cd6067bd..9ca9815c 100644
--- a/docs/language/builtins/fn-fx.md
+++ b/docs/language/builtins/fn-fx.md
@@ -1,12 +1,26 @@
---
id: fn-fx
-name: fx / flr
+name: fx
category: builtins
kind: builtin
-tokens: fx flr
-sig: fx(n) -> fixed / flr(x) -> int
-tip: Convert between int and fixed-point.
-order: 10
+tokens: fx
+sig: fx(n) -> fixed
+tip: Lift an integer into a Q16.16 fixed-point value.
+order: 50
---
-Convert between int and fixed-point.
+Converts an integer into a fixed value (Ludic's Q16.16 fixed-point type), so it can take part in fractional arithmetic. Ludic has no floating point; fixed is how you carry sub-pixel precision for smooth movement and physics-like accumulation. Use fx when you need to combine an int with fixed-point values or divide to get a fraction — for example fx(1) / fx(4) is 0.25. Convert back to a whole number for drawing with flr.
+
+Parameters:
+- `n` — the integer to lift into fixed-point
+
+```ludic
+program SmoothAccumulate {
+ handler ComputeStep phase Start {
+ let full_speed = fx(3)
+ let half_speed = full_speed / fx(2)
+ let two_steps = half_speed + half_speed
+ print(flr(two_steps))
+ }
+}
+```
diff --git a/docs/language/builtins/fn-getenv.md b/docs/language/builtins/fn-getenv.md
index 68ee16af..66df9f70 100644
--- a/docs/language/builtins/fn-getenv.md
+++ b/docs/language/builtins/fn-getenv.md
@@ -5,8 +5,24 @@ category: builtins
kind: builtin
tokens: getenv
sig: getenv(name) -> str
-tip: Read an environment variable.
+tip: Read an environment variable, returning empty when it is unset.
order: 23
---
-Return the value of environment variable name (empty when it is unset).
+Returns the value of the named environment variable, or an empty string when that variable is not set. Use it to make command-line tools and headless runs configurable without recompiling — reading a data directory, a log level, or a seed from the environment. Check for the empty string to detect an unset variable and fall back to a default. Like the other system builtins, it is aimed at tooling rather than a shipped game loop.
+
+Parameters:
+- `name` — the environment variable name to read
+
+```ludic
+program ReadConfig {
+ handler ShowConfig phase Start {
+ let level = getenv("LUDIC_LEVEL")
+ if len(level) == 0 {
+ print("default")
+ } else {
+ print(level)
+ }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-len.md b/docs/language/builtins/fn-len.md
index 9b4c5080..5c25324e 100644
--- a/docs/language/builtins/fn-len.md
+++ b/docs/language/builtins/fn-len.md
@@ -5,8 +5,23 @@ category: builtins
kind: builtin
tokens: len
sig: len(x) -> int
-tip: Length of a slice or string.
+tip: The number of elements in a slice, or the byte length of a string.
order: 5
---
-Length of a slice or string.
+Returns the length of its argument: the element count of a slice, or the byte length of a string. Use it to iterate a slice with for index in 0 .. len(items), to check whether a collection is empty, or to compute string slice bounds such as trimming a suffix. It reads the current length, so after you push onto a slice the value it returns grows accordingly. For strings, note it counts bytes, not visual characters.
+
+Parameters:
+- `x` — a slice or string to measure
+
+```ludic
+program CountEnemies {
+ handler ReportCount phase Start {
+ let wave_sizes = new []int
+ push(wave_sizes, 3)
+ push(wave_sizes, 5)
+ push(wave_sizes, 8)
+ print(len(wave_sizes))
+ }
+}
+```
diff --git a/docs/language/builtins/fn-load.md b/docs/language/builtins/fn-load.md
index ce938ad3..8abe8aa2 100644
--- a/docs/language/builtins/fn-load.md
+++ b/docs/language/builtins/fn-load.md
@@ -5,8 +5,32 @@ category: builtins
kind: builtin
tokens: load
sig: load() -> bool
-tip: Restore a snapshot written by save().
+tip: Restore the world from a snapshot previously written by save().
order: 4
---
-Restore a snapshot written by save().
+Restores the entire world from the snapshot most recently written by save() — every model instance, property, and program var returns to its saved state. It returns a bool: true when a snapshot existed and was restored, false when there was nothing to load, so you can guard the call and avoid clobbering the current world by mistake. Use it for quick-load, respawning at a checkpoint, or resetting a test to a known state. It takes no arguments.
+
+```ludic
+program QuickLoad {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ save()
+ }
+
+ handler ReadInput phase Input {
+ if Input.key() == 'r' {
+ let restored = load()
+ if restored { print(1) }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-max.md b/docs/language/builtins/fn-max.md
new file mode 100644
index 00000000..6e64dce1
--- /dev/null
+++ b/docs/language/builtins/fn-max.md
@@ -0,0 +1,35 @@
+---
+id: fn-max
+name: max
+category: builtins
+kind: builtin
+tokens: max
+sig: max(a, b) -> int
+tip: The larger of two integers.
+order: 50
+---
+
+Returns whichever of its two integer arguments is larger. Use it to enforce a lower bound (for example clamping a countdown so it never drops below zero with max(remaining, 0)), to track a running high score, or to pick the greater of two candidate values. For the smaller of two values use min, and to bound a value on both sides at once use clamp.
+
+Parameters:
+- `a` — the first value
+- `b` — the second value
+
+```ludic
+program HighScore {
+ var score: int = 0
+ var best_score: int = 0
+
+ handler EarnPoints phase Update {
+ score = score + 5
+ best_score = max(best_score, score)
+ if score >= 20 { quit() }
+ }
+
+ handler ReportBest phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 8, y: 8, value: best_score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-min.md b/docs/language/builtins/fn-min.md
index b215df4b..a752a4bb 100644
--- a/docs/language/builtins/fn-min.md
+++ b/docs/language/builtins/fn-min.md
@@ -1,12 +1,41 @@
---
id: fn-min
-name: min / max
+name: min
category: builtins
kind: builtin
-tokens: min max
-sig: min(a, b) / max(a, b) -> int
-tip: The smaller / larger of two ints.
-order: 7
+tokens: min
+sig: min(a, b) -> int
+tip: The smaller of two integers.
+order: 50
---
-The smaller / larger of two ints.
+Returns whichever of its two integer arguments is smaller. Use it to enforce an upper bound — for instance capping healing so it never exceeds a maximum with min(current + heal, maximum) — or to pick the lesser of two candidate values. For the larger of two values use max, and to bound a value between a low and a high at once use clamp.
+
+Parameters:
+- `a` — the first value
+- `b` — the second value
+
+```ludic
+program CappedHealing {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Health }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Health { current: 90, maximum: 100 } }
+ }
+
+ handler ApplyHeal phase Update {
+ for (Health) in query [Health, {Player}] {
+ Health.current = min(Health.current + 25, Health.maximum)
+ }
+ }
+
+ handler ReportHealth phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Health) in query [Health, {Player}] {
+ Screen.draw_number(x: 8, y: 8, value: Health.current, color: Color.LimeGreen, scale: 1)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-print.md b/docs/language/builtins/fn-print.md
index f0f96104..785a2e39 100644
--- a/docs/language/builtins/fn-print.md
+++ b/docs/language/builtins/fn-print.md
@@ -5,8 +5,23 @@ category: builtins
kind: builtin
tokens: print
sig: print(x)
-tip: Print an int or string, followed by a newline — for headless tests and debugging.
+tip: Print an int or string followed by a newline — for headless tests and debugging.
order: 0
---
-Print an int or string, followed by a newline — for headless tests and debugging.
+Writes its argument to standard output followed by a newline. It accepts either an integer or a string, so it is the go-to tool for quick debugging and for headless test programs that emit numbers a test harness can check. It is a diagnostic channel, separate from anything drawn on screen with the Screen API, and is most useful in Start- or Update-phase handlers while you are iterating. For interpolated messages, build the string first with a backtick template and pass that.
+
+Parameters:
+- `x` — the value to print, an `int` or a string
+
+```ludic
+program PrintScore {
+ var score: int = 0
+
+ handler CountUp phase Update {
+ score = score + 10
+ print(score)
+ if score >= 30 { quit() }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-push.md b/docs/language/builtins/fn-push.md
index 164eab50..a302d6a7 100644
--- a/docs/language/builtins/fn-push.md
+++ b/docs/language/builtins/fn-push.md
@@ -5,8 +5,25 @@ category: builtins
kind: builtin
tokens: push
sig: push(slice, x)
-tip: Append to a slice.
+tip: Append one element to the end of a growable slice.
order: 6
---
-Append to a slice.
+Appends a value to the end of a slice, growing it by one element. Create the slice first with new []T, then push items onto it; afterward len reflects the new count and the element is reachable by index. Use it to build up lists at runtime — a queue of spawn requests, collected scores, parsed tokens — where you don't know the size in advance. The value's type must match the slice's element type.
+
+Parameters:
+- `slice` — the growable slice to append to
+- `x` — the element to append
+
+```ludic
+program BuildWaveList {
+ handler PlanWaves phase Start {
+ let wave_sizes = new []int
+ push(wave_sizes, 4)
+ push(wave_sizes, 6)
+ for index in 0 .. len(wave_sizes) {
+ print(wave_sizes[index])
+ }
+ }
+}
+```
diff --git a/docs/language/builtins/fn-quit.md b/docs/language/builtins/fn-quit.md
index 8f97bc90..2018eee7 100644
--- a/docs/language/builtins/fn-quit.md
+++ b/docs/language/builtins/fn-quit.md
@@ -5,8 +5,22 @@ category: builtins
kind: builtin
tokens: quit
sig: quit()
-tip: Stop the game loop after this frame.
+tip: Stop the game loop cleanly after the current frame finishes.
order: 2
---
-Stop the game loop after this frame.
+Signals the runtime to stop the game loop after the current frame completes, ending the program in an orderly way. Because it lets the in-progress frame finish, any drawing you have already queued for this frame is still presented. Use it for a "quit to desktop" action, to end a headless test once its work is done, or to stop on a win/lose condition. For an immediate, mid-frame stop with a status code instead, use exit. It takes no arguments.
+
+```ludic
+program QuitOnKey {
+ handler ReadInput phase Input {
+ if Input.key() == 'q' { quit() }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 8, y: 8, text: "PRESS Q TO QUIT", color: Color.White, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-read_char.md b/docs/language/builtins/fn-read_char.md
index beabf9af..a9923a81 100644
--- a/docs/language/builtins/fn-read_char.md
+++ b/docs/language/builtins/fn-read_char.md
@@ -5,8 +5,22 @@ category: builtins
kind: builtin
tokens: read_char
sig: read_char() -> int
-tip: Read one byte from standard input (-1 at end of input).
+tip: Read one byte from standard input, or -1 at end of input.
order: 24
---
-Read a single byte from standard input, returning its value or -1 at end of input — the basis for headless, pipe-driven runs.
+Reads a single byte from standard input and returns its value, or -1 when the input has ended. It is the foundation for headless, pipe-driven runs: a test or tool can feed the program a stream of bytes and process them one at a time, looping until read_char returns -1. Compare the byte against character literals such as 'a' to interpret it. It takes no arguments and advances through the input on each call.
+
+```ludic
+program CountInputBytes {
+ handler DrainInput phase Start {
+ var byte_count: int = 0
+ var next_byte = read_char()
+ while next_byte != -1 {
+ byte_count = byte_count + 1
+ next_byte = read_char()
+ }
+ print(byte_count)
+ }
+}
+```
diff --git a/docs/language/builtins/fn-run.md b/docs/language/builtins/fn-run.md
index 08b8db42..a27a608c 100644
--- a/docs/language/builtins/fn-run.md
+++ b/docs/language/builtins/fn-run.md
@@ -5,8 +5,20 @@ category: builtins
kind: builtin
tokens: run
sig: run(cmd)
-tip: Run a shell command.
+tip: Run a string as a shell command.
order: 22
---
-Run cmd as a shell command — handy for build steps and tooling written in Ludic.
+Executes its string argument as a shell command on the host. It exists so build steps and developer tooling can be written in Ludic itself — invoking the compiler, moving files, or chaining tools — rather than in a separate shell script. It is a tooling and scripting facility, not something a shipped game loop would normally use. Treat the command string carefully, since it runs with the same privileges as the program.
+
+Parameters:
+- `cmd` — the shell command line to run
+
+```ludic
+program BuildStep {
+ handler RunTool phase Start {
+ run("mkdir -p build")
+ run("echo built")
+ }
+}
+```
diff --git a/docs/language/builtins/fn-save.md b/docs/language/builtins/fn-save.md
index 545b5808..d4c69d13 100644
--- a/docs/language/builtins/fn-save.md
+++ b/docs/language/builtins/fn-save.md
@@ -5,8 +5,30 @@ category: builtins
kind: builtin
tokens: save
sig: save()
-tip: Serialize the entire world — every entity, property and program var — to a snapshot in one call.
+tip: Serialize the whole world — every model instance, property, and program var — in one call.
order: 3
---
-Serialize the entire world — every entity, property and program var — to a snapshot in one call.
+Captures a complete snapshot of the running world in a single call: every spawned model instance and its properties, plus every program-level var. The compiler generates the serialization for you from your declarations, so you never write it by hand. Pair it with load to implement quick-save / quick-load, checkpoints, or deterministic test fixtures. It takes no arguments and writes to the runtime's snapshot slot; calling it again overwrites the previous snapshot.
+
+```ludic
+program QuickSave {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ }
+
+ handler ReadInput phase Input {
+ let pressed = Input.key()
+ if pressed == 's' { save() }
+ if pressed == 'l' { load() }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-str.md b/docs/language/builtins/fn-str.md
index 96f41bfc..9009e706 100644
--- a/docs/language/builtins/fn-str.md
+++ b/docs/language/builtins/fn-str.md
@@ -5,8 +5,22 @@ category: builtins
kind: builtin
tokens: str
sig: str(x) -> str
-tip: Convert an int/bool/fixed to text; a string passes through.
+tip: Convert an int, bool, or fixed value to text; a string passes through unchanged.
order: 1
---
-Convert an int/bool/fixed to text; a string passes through. Also used by `{…}` interpolation.
+Converts a value to its textual form: an int, bool, or fixed becomes a string, and a value that is already a string is returned unchanged. This is what backtick `{…}` interpolation calls under the hood, so most of the time you can interpolate directly instead of calling str yourself. Reach for the explicit form when you need to store or pass around the text, or build a string in pieces. The result can be printed, drawn with Screen.draw_text, or concatenated.
+
+Parameters:
+- `x` — the value to convert to text
+
+```ludic
+program LabelValue {
+ var score: int = 250
+
+ handler ReportScore phase Start {
+ let label = str(score)
+ print(label)
+ }
+}
+```
diff --git a/docs/language/builtins/fn-ui_build.md b/docs/language/builtins/fn-ui_build.md
new file mode 100644
index 00000000..f7c0a51c
--- /dev/null
+++ b/docs/language/builtins/fn-ui_build.md
@@ -0,0 +1,43 @@
+---
+id: fn-ui_build
+name: ui_build
+category: builtins
+kind: builtin
+tokens: ui_build
+sig: ui_build()
+tip: Build the declared UI tree so it can be opened and rendered.
+order: 50
+---
+
+Constructs the retained-UI tree you declared in a ui block, laying out its panels, labels, and buttons and loading any skins or images they reference. Call it once during a Start-phase handler, after loading any fonts the UI needs, and before you open a screen with ui_open or draw it with ui_render. Because the UI is declared as data and built in one step, the game code only has to build it, open it, and read clicks. It takes no arguments.
+
+```ludic
+program TitleMenu {
+ var title_font: int = 0
+
+ ui MainMenu {
+ panel id: Root w: 240 pad: 16 gap: 6 bg: 0x1a1a2c align: center {
+ label text: "CHRONORIFT" font: title_font size: 24 fg: 0xffe060 align: center
+ button id: NewGame text: "New Game" font: title_font size: 16 w: 200
+ button id: Quit text: "Quit" font: title_font size: 16 w: 200
+ }
+ }
+
+ handler Boot phase Start {
+ title_font = font_load("/System/Library/Fonts/Supplemental/Arial.ttf")
+ ui_build()
+ ui_open(UI_MainMenu)
+ }
+
+ handler Navigate phase Update {
+ ui_tick(Input.key())
+ if ui_clicked(UI_Quit) { quit() }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ ui_render()
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/builtins/fn-words.md b/docs/language/builtins/fn-words.md
new file mode 100644
index 00000000..c9566bdf
--- /dev/null
+++ b/docs/language/builtins/fn-words.md
@@ -0,0 +1,31 @@
+---
+id: fn-words
+name: words
+category: builtins
+kind: builtin
+tokens: words
+sig: words(n) -> words
+tip: Allocate a raw buffer of n 32-bit words, indexable with [i].
+order: 50
+---
+
+Allocates a raw buffer of n 32-bit words and returns a words value you can index with buffer[i] to read or write each word as an int. Use it when you want a flat, fixed-size numeric scratch array — a lookup table, a small ring buffer, or per-slot counters — without declaring a model. It is lower-level than a growable []int slice: the size is chosen up front and there is no push. For byte-granular storage use bytes instead.
+
+Parameters:
+- `n` — the number of 32-bit words to allocate
+
+```ludic
+program WordScratch {
+ handler SumScores phase Start {
+ let scores = words(3)
+ scores[0] = 10
+ scores[1] = 20
+ scores[2] = 30
+ var total: int = 0
+ for index in 0 .. 3 {
+ total = total + scores[index]
+ }
+ print(total)
+ }
+}
+```
diff --git a/docs/language/control/kw-become.md b/docs/language/control/kw-become.md
index c837082e..3e1fa2c7 100644
--- a/docs/language/control/kw-become.md
+++ b/docs/language/control/kw-become.md
@@ -5,8 +5,30 @@ category: control
kind: keyword
tokens: become
sig: become Name
-tip: Transition: to another state of the enclosing machine, or to another scene.
+tip: Transition to another state of the enclosing machine, or to another scene.
order: 8
---
-Transition: to another state of the enclosing machine, or to another scene.
+A become statement performs a transition. Inside a machine, `become Name` moves to another `state` of that machine by storing the target state's value back into the machine's store, so the next dispatch runs the new state. Used from a scene's layer handler, `become Scene` instead switches the active scene: the current scene's `on exit` runs, the active-scene register is set, and the target scene's `on enter` runs — two direct calls and a store, with no dispatch table. `become` names its target and knows from context which kind of transition it is, so you rarely think about the machinery. Transitioning is cheap and takes effect immediately for machine states.
+
+```ludic
+program EncounterFlow {
+ enum Stage { Explore, Battle, Victory }
+ var stage: int = Stage.Explore
+ var enemies_left: int = 2
+
+ handler RunStage phase Update {
+ machine stage {
+ state Explore {
+ if Input.key() == ' ' { become Battle }
+ }
+ state Battle {
+ if enemies_left <= 0 { become Victory }
+ }
+ state Victory {
+ Screen.status("you win")
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/control/kw-for.md b/docs/language/control/kw-for.md
index 6d3eec2f..227af61f 100644
--- a/docs/language/control/kw-for.md
+++ b/docs/language/control/kw-for.md
@@ -5,12 +5,28 @@ category: control
kind: keyword
tokens: for
sig: for name in a .. b { … }
-tip: Range loop.
+tip: Range loop — iterate the half-open range from a up to but not including b.
order: 2
---
-Range loop. Each pass binds a fresh, immutable name — it is not a variable you reuse or reassign; the range a .. b runs from a up to but not including b.
+The numeric for … in loop walks the half-open range `a .. b`, running the body for each value from `a` up to but not including `b`. Each pass binds a fresh, immutable name — it is not a reusable variable and cannot be reassigned inside the body — which makes the loop easy to reason about. Both bounds are ordinary expressions, so ranges built from constants like `0 .. GRID_WIDTH` read cleanly. The same `for … in` keyword also drives ECS query loops (`for (…) in query […]`); this page is the numeric range form. Use `break` and `continue` to exit early or skip to the next value.
```ludic
-for gy in 0 .. GRID_H { … }
+program Grid {
+ const GRID_WIDTH: int = 20
+ const GRID_HEIGHT: int = 15
+ const TILE_SIZE: int = 16
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for row in 0 .. GRID_HEIGHT {
+ for column in 0 .. GRID_WIDTH {
+ var tile_color = Color.MidnightBlue
+ if (column + row) % 2 == 0 { tile_color = Color.White }
+ Screen.fill_rectangle(x: column * TILE_SIZE, y: row * TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE, color: tile_color)
+ }
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/control/kw-if.md b/docs/language/control/kw-if.md
index 4957557f..5377e531 100644
--- a/docs/language/control/kw-if.md
+++ b/docs/language/control/kw-if.md
@@ -5,8 +5,35 @@ category: control
kind: keyword
tokens: if else
sig: if cond { … } else { … }
-tip: A branch.
+tip: A branch — run one block when a condition holds, another when it doesn't.
order: 0
---
-A branch. Conditions are plain expressions; no parentheses required.
+An if runs its block when the condition is true; an optional `else` block runs when it is false, and the `else` may itself be another `if` to form a ladder. The condition is a plain boolean expression with no surrounding parentheses, and the braces are always required even for a single statement. Ludic's boolean operators are the words `and`, `or` and `not` (not `&&`/`||`/`!`), and note that bitwise operators bind tighter than comparison, so `flags & MASK == 0` already means `(flags & MASK) == 0`. When the branches grow into a chain testing one value against several constants, reach for `match` instead.
+
+```ludic
+program Threshold {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Health }
+
+ handler Boot phase Start {
+ spawn Hero { Health { current: 25 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (health) in query [Health, {Player}] {
+ if health.current <= 0 {
+ Screen.draw_text(x: 8, y: 8, text: "DEFEATED", color: Color.Crimson, scale: 2)
+ } else {
+ if health.current < 30 {
+ Screen.draw_text(x: 8, y: 8, text: "DANGER", color: Color.Gold, scale: 2)
+ } else {
+ Screen.draw_text(x: 8, y: 8, text: "OK", color: Color.LimeGreen, scale: 2)
+ }
+ }
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/control/kw-in.md b/docs/language/control/kw-in.md
index 27e75a76..ceedb1fc 100644
--- a/docs/language/control/kw-in.md
+++ b/docs/language/control/kw-in.md
@@ -5,8 +5,28 @@ category: control
kind: keyword
tokens: in
sig: for x in range | query
-tip: Binds the loop name to each value of a range or query.
+tip: The part of a for loop that names what to iterate — a range or a query.
order: 3
---
-Binds the loop name to each value of a range or query.
+The in keyword is the bridge in a `for` loop between the loop's bindings and the source it walks. On the right of `in` you write either a numeric range, `a .. b`, binding one fresh index each pass, or an ECS `query [...]`, binding one variable per non-tag property for each matching model instance. It always pairs with `for` and never stands alone. In the query form the parentheses group the bound properties — `for (position, velocity) in query [Position, Velocity]` — and the body then runs once per instance that carries them all.
+
+```ludic
+program Movement {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Enemy { Position, Velocity }
+
+ handler Boot phase Start {
+ for slot in 0 .. 3 {
+ spawn Grunt { Position { column: slot * 4, row: 2 }; Velocity { delta_x: 1 } }
+ }
+ }
+
+ handler AdvancePositions phase Update {
+ for (position, velocity) in query [Position, Velocity, {Enemy}] {
+ position.column = position.column + velocity.delta_x
+ }
+ }
+}
+```
diff --git a/docs/language/control/kw-machine.md b/docs/language/control/kw-machine.md
index e94773e0..aade19d6 100644
--- a/docs/language/control/kw-machine.md
+++ b/docs/language/control/kw-machine.md
@@ -5,12 +5,35 @@ category: control
kind: keyword
tokens: machine
sig: machine store { state Name { … } }
-tip: A state machine over an int var (or register).
+tip: An explicit state machine over an int var — dispatches on the store's value.
order: 6
---
-A state machine over an int var (or register). It dispatches on the store's value.
+A machine turns an integer store into an explicit state machine, replacing brittle `if phase == N` chains. It reads the store — a named program-scope `var` is the modern choice — and dispatches to the matching `state` block; inside a state, `become Name` transitions to another state of the same machine. States number themselves by declaration order (the first is `0`, the next `1`, and so on), so you never write magic constants, though `state Name = expr` is accepted when a state needs a specific value. Because `become` compiles to a single store back into the `var`, the whole machine lowers to plain branches with no dispatch table. Place a `machine` inside a handler so it runs each frame.
```ludic
-machine turn_phase { state KnightMenu { … } }
+program TurnOrder {
+ enum Phase { KnightMenu, KnightResolve, EnemyTurn }
+ var battle_phase: int = Phase.KnightMenu
+
+ handler RunTurn phase Update {
+ machine battle_phase {
+ state KnightMenu {
+ if Input.key() == ' ' { become KnightResolve }
+ }
+ state KnightResolve {
+ become EnemyTurn
+ }
+ state EnemyTurn {
+ become KnightMenu
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 8, y: 8, value: battle_phase, color: Color.Gold, scale: 2)
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/control/kw-match.md b/docs/language/control/kw-match.md
index 6b4f5059..cae55ad9 100644
--- a/docs/language/control/kw-match.md
+++ b/docs/language/control/kw-match.md
@@ -1,12 +1,36 @@
---
id: kw-match
-name: match / when
+name: match
category: control
kind: keyword
tokens: match
-sig: match x { when a, b => … }
-tip: Multi-way branch on a value, matching one or more literals per arm.
+sig: match value { 0, 1 => … _ => … }
+tip: Multi-way branch on one value, matching one or more literals per arm.
order: 4
---
-Multi-way branch on a value, matching one or more literals per arm.
+A match replaces an `if`/`else` ladder that tests one value against several constants. It evaluates the subject once, then takes the first arm whose pattern matches; an arm lists one or more literal patterns separated by commas and points at a body with `=>`, and a lone `_` arm is the catch-all default. Patterns are compile-time constants — integers, char literals like `'w'`, or `enum` variants such as `Action.Guard` — which makes `match` ideal for dispatching on a key press, a tile code, or a mode. Each arm's body is a single statement or a `{ … }` block; matching lowers to plain branches, so it is as cheap as the `if` chain it replaces.
+
+```ludic
+program Steering {
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Player { Velocity }
+
+ handler Boot phase Start {
+ spawn Hero { Velocity { delta_x: 0, delta_y: 0 } }
+ }
+
+ handler ReadKeys phase Input {
+ let pressed = Input.key()
+ for (velocity) in query [Velocity, {Player}] {
+ match pressed {
+ 'w' => velocity.delta_y = -1
+ 's' => velocity.delta_y = 1
+ 'a', 'h' => velocity.delta_x = -1
+ 'd', 'l' => velocity.delta_x = 1
+ _ => velocity.delta_x = 0
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/control/kw-state.md b/docs/language/control/kw-state.md
index e4c4a91a..0ed8a595 100644
--- a/docs/language/control/kw-state.md
+++ b/docs/language/control/kw-state.md
@@ -5,8 +5,31 @@ category: control
kind: keyword
tokens: state
sig: state Name { … }
-tip: One state of a machine.
+tip: One state of a machine — its body runs while the machine sits in it.
order: 7
---
-One state of a machine.
+A state declares one state of an enclosing machine: a named block whose body runs while the machine's store holds that state's value. States take their value from declaration order — the first `state` is `0`, the next `1`, and so on — so you refer to them by name and never track the numbers yourself (write `state Name = expr` only when a state must have a specific value). From inside a state, `become OtherName` transitions the machine by storing the target state's value back into the store. Keep each state focused on the logic for that mode and hand off with `become` when its condition to move on is met.
+
+```ludic
+program DoorControl {
+ enum DoorState { Closed, Opening, Open }
+ var door: int = DoorState.Closed
+ var elapsed_frames: int = 0
+
+ handler RunDoor phase Update {
+ machine door {
+ state Closed {
+ if Input.key() == ' ' { elapsed_frames = 0; become Opening }
+ }
+ state Opening {
+ elapsed_frames = elapsed_frames + 1
+ if elapsed_frames >= 30 { become Open }
+ }
+ state Open {
+ Screen.status("door open")
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/control/kw-when.md b/docs/language/control/kw-when.md
deleted file mode 100644
index 976e723c..00000000
--- a/docs/language/control/kw-when.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-id: kw-when
-name: when
-category: control
-kind: keyword
-tokens: when
-sig: when value => result
-tip: One arm of a match.
-order: 5
----
-
-One arm of a match.
diff --git a/docs/language/control/kw-while.md b/docs/language/control/kw-while.md
index 8a75d60f..c38feb2d 100644
--- a/docs/language/control/kw-while.md
+++ b/docs/language/control/kw-while.md
@@ -5,8 +5,29 @@ category: control
kind: keyword
tokens: while
sig: while cond { … }
-tip: Loop while the condition holds.
+tip: Loop as long as a condition holds, re-checking it before each pass.
order: 1
---
-Loop while the condition holds.
+A while loop re-evaluates its condition before every pass and runs the body as long as it stays true, so it is the tool when the number of iterations is not known up front. As with `if`, the condition needs no parentheses and the braces are required. `break` leaves the loop immediately and `continue` jumps to the next condition check. When you are simply counting over a fixed range, prefer the numeric `for i in a .. b` loop, which is clearer and binds a fresh index for you; reach for `while` when the step or the stopping test is irregular.
+
+```ludic
+program Countdown {
+ var fuse: int = 5
+ var elapsed_frames: int = 0
+
+ handler Tick phase Update {
+ elapsed_frames = elapsed_frames + 1
+ while fuse > 0 and elapsed_frames % 60 == 0 {
+ fuse = fuse - 1
+ elapsed_frames = elapsed_frames + 1
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 8, y: 8, value: fuse, color: Color.Crimson, scale: 3)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_attach_dyn.md b/docs/language/ecs/fn-world_attach_dyn.md
new file mode 100644
index 00000000..dea93438
--- /dev/null
+++ b/docs/language/ecs/fn-world_attach_dyn.md
@@ -0,0 +1,31 @@
+---
+id: fn-world_attach_dyn
+name: world_attach_dyn
+category: ecs
+kind: builtin
+tokens: world_attach_dyn
+sig: world_attach_dyn(target, property)
+tip: Attach a property to an instance by numeric id at runtime (dynamic ECS).
+order: 50
+---
+
+world_attach_dyn adds a property to a live instance by numeric id, the reflection-ABI counterpart of the static `attach Prop on instance`. It is the tool a mod uses to grant a runtime-registered property (from `world_register_prop`) — or any property known only by id — to an instance, after which the instance's fields can be written with `world_set`. It returns nothing and sets the instance's has-flag for that property. Pair it with `world_detach_dyn` to remove. In ordinary compiled code, prefer the `attach` statement, which also fires `@OnAttach` hooks and seeds field overrides.
+
+Parameters:
+- `target` — the model instance to modify, an `entity` handle
+- `property` — the property id to attach, from `world_prop_id` or `world_register_prop`
+
+```ludic
+program GrantComponent {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 4, row: 4 } }
+ let stamina_property = world_register_prop("Stamina", 1)
+ for (position) in query [Position, {Player}] {
+ world_attach_dyn(self(), stamina_property)
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_count.md b/docs/language/ecs/fn-world_count.md
new file mode 100644
index 00000000..8d21f850
--- /dev/null
+++ b/docs/language/ecs/fn-world_count.md
@@ -0,0 +1,31 @@
+---
+id: fn-world_count
+name: world_count
+category: ecs
+kind: builtin
+tokens: world_count
+sig: world_count() -> int
+tip: The total number of live model instances in the world.
+order: 50
+---
+
+world_count returns the total number of live model instances in the world, as an `int` — a quick census without walking the world yourself. It takes no arguments and counts every spawned instance regardless of which properties it carries, so it is handy for a mod or debug overlay that wants a running tally of everything alive using the reflection ABI rather than a `query` loop. To resolve names to numeric ids for the other reflection calls, see `world_prop_id`; to read a specific instance's fields by id, see `world_get`.
+
+```ludic
+program Census {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ handler SpawnEnemies phase Start {
+ spawn Enemy { Position { column: 3, row: 3 } }
+ spawn Enemy { Position { column: 9, row: 3 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ let alive = world_count()
+ Screen.draw_number(x: 8, y: 8, value: alive, color: Color.Gold, scale: 2)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_detach_dyn.md b/docs/language/ecs/fn-world_detach_dyn.md
new file mode 100644
index 00000000..34b150c1
--- /dev/null
+++ b/docs/language/ecs/fn-world_detach_dyn.md
@@ -0,0 +1,36 @@
+---
+id: fn-world_detach_dyn
+name: world_detach_dyn
+category: ecs
+kind: builtin
+tokens: world_detach_dyn
+sig: world_detach_dyn(target, property)
+tip: Detach a property from an instance by numeric id at runtime (dynamic ECS).
+order: 50
+---
+
+world_detach_dyn removes a property from a live instance by numeric id, the reflection-ABI counterpart of the static `detach Prop on instance`. It clears the instance's has-flag for that property so queries and `world_has` no longer see it, and returns nothing. Use it to take back a runtime-registered or id-only property a mod previously granted with `world_attach_dyn`. In ordinary compiled code, prefer the `detach` statement, which also fires the `@OnDetach` hook before the flag clears.
+
+Parameters:
+- `target` — the model instance to modify, an `entity` handle
+- `property` — the property id to detach, from `world_prop_id` or `world_register_prop`
+
+```ludic
+program RevokeComponent {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 4, row: 4 } }
+ }
+
+ handler DropStamina phase Update {
+ let stamina_property = world_prop_id("Stamina")
+ if stamina_property >= 0 {
+ for (position) in query [Position, {Player}] {
+ world_detach_dyn(self(), stamina_property)
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_field_id.md b/docs/language/ecs/fn-world_field_id.md
new file mode 100644
index 00000000..4a68d37b
--- /dev/null
+++ b/docs/language/ecs/fn-world_field_id.md
@@ -0,0 +1,33 @@
+---
+id: fn-world_field_id
+name: world_field_id
+category: ecs
+kind: builtin
+tokens: world_field_id
+sig: world_field_id(property, name) -> int
+tip: Resolve a field name within a property to its numeric index.
+order: 50
+---
+
+world_field_id resolves a field name within a given property to its numeric index, which is what `world_get` and `world_set` use to pick which field to read or write. You pass a property id (from `world_prop_id`) and the field's name, and get back its index (negative if the property has no such field). Together the trio `world_prop_id` → `world_field_id` → `world_get`/`world_set` lets a mod or tool touch any field on any instance entirely by name, without the property being known at compile time.
+
+Parameters:
+- `property` — the property id, from `world_prop_id`
+- `name` — the field's name within that property, e.g. `"column"`
+
+```ludic
+program ResolveField {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 3, row: 3 } }
+ }
+
+ handler Inspect phase Update {
+ let position_property = world_prop_id("Position")
+ let column_field = world_field_id(position_property, "column")
+ Screen.status(str(column_field))
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_get.md b/docs/language/ecs/fn-world_get.md
index 9d6c8c7e..2f52381e 100644
--- a/docs/language/ecs/fn-world_get.md
+++ b/docs/language/ecs/fn-world_get.md
@@ -1,12 +1,37 @@
---
id: fn-world_get
-name: world_get / world_set / world_has …
+name: world_get
category: ecs
kind: builtin
-tokens: world_get world_set world_has world_count world_spawn
-sig: world_get(entity, prop, field) -> int
-tip: The reflection ABI: read and write the world by numeric id, for tools and mods.
-order: 8
+tokens: world_get
+sig: world_get(target, property, field) -> int
+tip: Read one field of a model instance by numeric id (reflection ABI).
+order: 50
---
-The reflection ABI: read and write the world by numeric id, for tools and mods. See also world_count, world_spawn.
+world_get reads a single field of a model instance entirely by numeric id, returning its value as an `int`. It is the id-based counterpart to the ordinary `position.column` field access that queries give you — used when the property is not known at compile time, as in a mod or a generic tool. You supply the target instance (an `entity`), the property id (from `world_prop_id`), and the field id (from `world_field_id`); the result is the stored value. Pair it with `world_set` to write. Reading a field of an instance that lacks the property is not meaningful, so guard with `world_has` first if unsure.
+
+Parameters:
+- `target` — the model instance to read from, an `entity` handle
+- `property` — the property id, from `world_prop_id`
+- `field` — the field id within that property, from `world_field_id`
+
+```ludic
+program ReadField {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 7, row: 2 } }
+ }
+
+ handler Inspect phase Update {
+ let position_property = world_prop_id("Position")
+ let column_field = world_field_id(position_property, "column")
+ for (position) in query [Position, {Player}] {
+ let value = world_get(self(), position_property, column_field)
+ Screen.status(str(value))
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_has.md b/docs/language/ecs/fn-world_has.md
new file mode 100644
index 00000000..c6d95a43
--- /dev/null
+++ b/docs/language/ecs/fn-world_has.md
@@ -0,0 +1,35 @@
+---
+id: fn-world_has
+name: world_has
+category: ecs
+kind: builtin
+tokens: world_has
+sig: world_has(target, property) -> bool
+tip: Test whether a model instance currently carries a property.
+order: 50
+---
+
+world_has tests whether a model instance currently carries a given property, returning a boolean. It is the reflection-ABI equivalent of the membership test a `query` performs implicitly, and the safe guard to run before a `world_get`/`world_set` on a property an instance might not have. A property that has been `disable`d reads as absent here (its has-flag is clear), while one that is merely present with default values reads as present. Pass the target instance and the property id from `world_prop_id`.
+
+Parameters:
+- `target` — the model instance to test, an `entity` handle
+- `property` — the property id, from `world_prop_id`
+
+```ludic
+program CheckProperty {
+ property Position { column: int = 0, row: int = 0 }
+ property Shield { amount: int = 0 }
+ model Player { Position, Shield }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 1, row: 1 }; Shield { amount: 2 } }
+ }
+
+ handler Inspect phase Update {
+ let shield_property = world_prop_id("Shield")
+ for (position) in query [Position, {Player}] {
+ if world_has(self(), shield_property) { Screen.status("shielded") }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_kind.md b/docs/language/ecs/fn-world_kind.md
new file mode 100644
index 00000000..ca6edee6
--- /dev/null
+++ b/docs/language/ecs/fn-world_kind.md
@@ -0,0 +1,35 @@
+---
+id: fn-world_kind
+name: world_kind
+category: ecs
+kind: builtin
+tokens: world_kind
+sig: world_kind(target) -> int
+tip: The model id of an instance — which kind of thing it is.
+order: 50
+---
+
+world_kind returns the model id of a given instance — that is, which kind of thing it is — as an `int`. Compare the result against ids from `world_model_id` to branch generically on an instance's kind without knowing it at compile time, the reflection-ABI stand-in for the `{Model}` tag filter in a static `query`. It is the natural partner of `world_query_next` when you walk instances by id and need to tell players from enemies. Pass the instance handle you want to classify.
+
+Parameters:
+- `target` — the model instance to classify, an `entity` handle
+
+```ludic
+program Classify {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+ model Enemy { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 2, row: 2 } }
+ spawn Grunt { Position { column: 8, row: 2 } }
+ }
+
+ handler Inspect phase Update {
+ let enemy_model = world_model_id("Enemy")
+ for (position) in query [Position] {
+ if world_kind(self()) == enemy_model { Screen.status("found an enemy") }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_load.md b/docs/language/ecs/fn-world_load.md
new file mode 100644
index 00000000..17c5260c
--- /dev/null
+++ b/docs/language/ecs/fn-world_load.md
@@ -0,0 +1,35 @@
+---
+id: fn-world_load
+name: world_load
+category: ecs
+kind: builtin
+tokens: world_load
+sig: world_load(buf, len)
+tip: Restore the whole world from a serialized byte buffer.
+order: 50
+---
+
+world_load restores the ECS world from a byte buffer previously produced by `world_save`, replacing the current world with the snapshot's instances and their fields. You pass the buffer and the number of valid bytes in it (the count `world_save` returned), and the world is rebuilt in place; it returns nothing. This is the low-level, buffer-based counterpart of `world_save` — the pair a mod or tool uses to move a whole world across the ABI or between storage. For a simple whole-game load from the default slot, the higher-level `load()` builtin is usually enough.
+
+Parameters:
+- `buf` — the source byte buffer holding a snapshot
+- `len` — the number of valid bytes in `buf`
+
+```ludic
+program SnapshotRoundTrip {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ var snapshot_length: int = 0
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 3, row: 3 } }
+ }
+
+ handler RoundTrip phase Update {
+ let buffer = bytes(4096)
+ if Input.key() == 's' { snapshot_length = world_save(buffer) }
+ if Input.key() == 'l' { world_load(buffer, snapshot_length) }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_model_id.md b/docs/language/ecs/fn-world_model_id.md
new file mode 100644
index 00000000..c3fc3175
--- /dev/null
+++ b/docs/language/ecs/fn-world_model_id.md
@@ -0,0 +1,31 @@
+---
+id: fn-world_model_id
+name: world_model_id
+category: ecs
+kind: builtin
+tokens: world_model_id
+sig: world_model_id(name) -> int
+tip: Resolve a model's name to its stable numeric id.
+order: 50
+---
+
+world_model_id looks up a model by name and returns its stable numeric id, or a negative value if no model of that name exists. It is part of Ludic's reflection ABI — the dynamic, id-based view of the world that mods and tools use alongside the static `spawn`/`query` forms. You typically resolve a name to an id once and then feed that id to id-based calls such as `world_spawn` or `world_kind`. Because the id is stable for a given build, it is safe to cache in a `var`.
+
+Parameters:
+- `name` — the model's name, as a string, e.g. `"Player"`
+
+```ludic
+program ResolveModel {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 3, row: 3 } }
+ }
+
+ handler Inspect phase Update {
+ let player_model = world_model_id("Player")
+ Screen.status(str(player_model))
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_prop_id.md b/docs/language/ecs/fn-world_prop_id.md
new file mode 100644
index 00000000..5b1b8afa
--- /dev/null
+++ b/docs/language/ecs/fn-world_prop_id.md
@@ -0,0 +1,31 @@
+---
+id: fn-world_prop_id
+name: world_prop_id
+category: ecs
+kind: builtin
+tokens: world_prop_id
+sig: world_prop_id(name) -> int
+tip: Resolve a property's name to its stable numeric id.
+order: 50
+---
+
+world_prop_id resolves a property by name to its stable numeric id, returning a negative value when no such property exists. It is the entry point to the reflection ABI's field access: pass the returned property id to `world_has`, `world_count`, `world_get`/`world_set` (together with a field id from `world_field_id`), and the dynamic attach/detach and query calls. Resolve the id once and cache it in a `var` rather than looking it up every frame. This id-based view complements the static `query [...]`, which is how ordinary handlers reach properties by name.
+
+Parameters:
+- `name` — the property's name, as a string, e.g. `"Position"`
+
+```ludic
+program ResolveProperty {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 3, row: 3 } }
+ }
+
+ handler Inspect phase Update {
+ let position_property = world_prop_id("Position")
+ Screen.status(str(position_property))
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_query_next.md b/docs/language/ecs/fn-world_query_next.md
new file mode 100644
index 00000000..e84483b0
--- /dev/null
+++ b/docs/language/ecs/fn-world_query_next.md
@@ -0,0 +1,38 @@
+---
+id: fn-world_query_next
+name: world_query_next
+category: ecs
+kind: builtin
+tokens: world_query_next
+sig: world_query_next(property, cursor) -> entity
+tip: Step to the next instance carrying a property, walking the world by id.
+order: 50
+---
+
+world_query_next walks the world by ascending instance id, returning the next live instance at or after the cursor that carries the given property, or a negative value when there are no more. It is the reflection-ABI equivalent of a `for (…) in query [Property]` loop, expressed as an explicit step so a mod or tool can iterate matching instances by id. Drive it with a loop: start the cursor at `0`, and after each hit advance the cursor to one past the returned id before calling again. Pass the property id from `world_prop_id`.
+
+Parameters:
+- `property` — the property id to match, from `world_prop_id`
+- `cursor` — the id to start scanning from; advance it past each hit to continue
+
+```ludic
+program WalkInstances {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ handler Boot phase Start {
+ spawn GruntA { Position { column: 2, row: 2 } }
+ spawn GruntB { Position { column: 5, row: 2 } }
+ }
+
+ handler Sweep phase Update {
+ let position_property = world_prop_id("Position")
+ var cursor = 0
+ var found = world_query_next(position_property, cursor)
+ while found >= 0 {
+ cursor = found + 1
+ found = world_query_next(position_property, cursor)
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_register_prop.md b/docs/language/ecs/fn-world_register_prop.md
new file mode 100644
index 00000000..a51fb2b6
--- /dev/null
+++ b/docs/language/ecs/fn-world_register_prop.md
@@ -0,0 +1,29 @@
+---
+id: fn-world_register_prop
+name: world_register_prop
+category: ecs
+kind: builtin
+tokens: world_register_prop
+sig: world_register_prop(name, fields) -> int
+tip: Register a brand-new property at runtime and get its id (dynamic ECS).
+order: 50
+---
+
+world_register_prop declares a brand-new property at runtime — one not present in the compiled program — and returns its property id. This is the dynamic-ECS entry point for mods: a mod can add its own component to the world, then `world_attach_dyn` it onto instances and read and write its fields with `world_get`/`world_set`. You give the property a name and its number of fields; the returned id then works everywhere a compiled property id does. Compiled programs that need no runtime-defined properties never call this — they declare `property` blocks at compile time instead.
+
+Parameters:
+- `name` — the new property's name, as a string
+- `fields` — how many integer fields it has
+
+```ludic
+program ModComponent {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 4, row: 4 } }
+ let stamina_property = world_register_prop("Stamina", 1)
+ Screen.status(str(stamina_property))
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_save.md b/docs/language/ecs/fn-world_save.md
new file mode 100644
index 00000000..5421e5f6
--- /dev/null
+++ b/docs/language/ecs/fn-world_save.md
@@ -0,0 +1,34 @@
+---
+id: fn-world_save
+name: world_save
+category: ecs
+kind: builtin
+tokens: world_save
+sig: world_save(buf) -> int
+tip: Serialize the whole world into a buffer; returns the number of bytes written.
+order: 50
+---
+
+world_save serializes the entire ECS world — every live instance, its kind, and all its property fields — into a byte buffer you provide, and returns the number of bytes written. It is the low-level, buffer-based form of a save: allocate a buffer with `bytes(n)` large enough to hold the snapshot, pass it in, and persist the written bytes (or hand them across the mod ABI). The exact count comes back so you know how much of the buffer is meaningful. Restore a snapshot later with `world_load`. For a simple whole-game save to the default slot, the higher-level `save()` builtin is usually enough.
+
+Parameters:
+- `buf` — the destination byte buffer, e.g. from `bytes(n)`
+
+```ludic
+program SnapshotOut {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 3, row: 3 } }
+ }
+
+ handler Persist phase Update {
+ if Input.key() == 's' {
+ let buffer = bytes(4096)
+ let written = world_save(buffer)
+ Screen.status(str(written))
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_set.md b/docs/language/ecs/fn-world_set.md
new file mode 100644
index 00000000..469fd983
--- /dev/null
+++ b/docs/language/ecs/fn-world_set.md
@@ -0,0 +1,37 @@
+---
+id: fn-world_set
+name: world_set
+category: ecs
+kind: builtin
+tokens: world_set
+sig: world_set(target, property, field, value)
+tip: Write one field of a model instance by numeric id (reflection ABI).
+order: 50
+---
+
+world_set writes a single field of a model instance by numeric id — the id-based counterpart to an ordinary `position.column = …` assignment, used when the property is not known at compile time. You give the target instance, the property id (from `world_prop_id`), the field id (from `world_field_id`), and the new value; the value is stored just as a direct assignment would. It returns nothing. Together with `world_get`, `world_has`, and the resolver functions, it lets a mod or tool mutate any field on any instance generically, which is how the reflection ABI mirrors the compiled `query`/field access at runtime.
+
+Parameters:
+- `target` — the model instance to write to, an `entity` handle
+- `property` — the property id, from `world_prop_id`
+- `field` — the field id within that property, from `world_field_id`
+- `value` — the new integer value to store
+
+```ludic
+program WriteField {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 0, row: 0 } }
+ }
+
+ handler Nudge phase Update {
+ let position_property = world_prop_id("Position")
+ let column_field = world_field_id(position_property, "column")
+ for (position) in query [Position, {Player}] {
+ world_set(self(), position_property, column_field, position.column + 1)
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_size.md b/docs/language/ecs/fn-world_size.md
new file mode 100644
index 00000000..3cdf92d9
--- /dev/null
+++ b/docs/language/ecs/fn-world_size.md
@@ -0,0 +1,32 @@
+---
+id: fn-world_size
+name: world_size
+category: ecs
+kind: builtin
+tokens: world_size
+sig: world_size() -> int
+tip: The number of live model instances in the world.
+order: 50
+---
+
+world_size returns the total number of live model instances in the world, across every kind, as an `int`. It takes no arguments and is the cheapest way to answer "how big is the world right now?" — useful for a debug readout, a capacity check before a big spawn, or a mod inspecting the running game through the reflection ABI. For a count restricted to instances carrying a particular property, use `world_count` instead.
+
+```ludic
+program WorldSize {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ handler Boot phase Start {
+ spawn GruntA { Position { column: 4, row: 4 } }
+ spawn GruntB { Position { column: 6, row: 4 } }
+ spawn GruntC { Position { column: 8, row: 4 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ let total = world_size()
+ Screen.draw_number(x: 8, y: 8, value: total, color: Color.White, scale: 2)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/ecs/fn-world_spawn.md b/docs/language/ecs/fn-world_spawn.md
new file mode 100644
index 00000000..9520bc40
--- /dev/null
+++ b/docs/language/ecs/fn-world_spawn.md
@@ -0,0 +1,30 @@
+---
+id: fn-world_spawn
+name: world_spawn
+category: ecs
+kind: builtin
+tokens: world_spawn
+sig: world_spawn(model) -> entity
+tip: Spawn an instance of a model chosen by numeric id at runtime.
+order: 50
+---
+
+world_spawn creates a new instance of a model chosen by numeric id and returns its `entity` handle — the reflection-ABI counterpart of the static `spawn Label { … }`. Because the model is given as an id (from `world_model_id`) rather than a name written in source, a mod or tool can decide at runtime which kind to create. The new instance is seeded exactly as the static spawn would seed it — every property in the model attached with default field values — after which you can set fields with `world_set`. Use the ordinary `spawn` statement in normal handler code; reach for `world_spawn` when the kind is data, not a literal.
+
+Parameters:
+- `model` — the model id to instantiate, from `world_model_id`
+
+```ludic
+program Reinforcements {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ handler Reinforce phase Update {
+ if Input.key() == ' ' {
+ let enemy_model = world_model_id("Enemy")
+ let new_enemy = world_spawn(enemy_model)
+ Screen.status(str(new_enemy))
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-attach.md b/docs/language/ecs/kw-attach.md
index 45b708ee..4eeed0d9 100644
--- a/docs/language/ecs/kw-attach.md
+++ b/docs/language/ecs/kw-attach.md
@@ -5,8 +5,28 @@ category: ecs
kind: keyword
tokens: attach
sig: attach Prop on entity { overrides }
-tip: Add a property to a live entity.
+tip: Structurally add a property to a live model instance, seeding its fields.
order: 6
---
-Add a property to a live entity.
+attach adds a property to a live model instance that did not have it, seeding the fields from their defaults plus any overrides in the `{ … }` block, and fires the property's `@OnAttach(Prop)` hook. It is a structural change — it changes what the instance has — as opposed to `enable`, which merely resumes a property the instance already carries. Attaching a property the instance already has is a no-op, so the hook only runs on a real transition. Use it to grant something new at runtime: give a player a shield pickup, tag an enemy as enraged, or add a component only some instances need.
+
+```ludic
+program Pickups {
+ property Position { column: int = 0, row: int = 0 }
+ property Shield { amount: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 6, row: 6 } }
+ }
+
+ handler GrabShield phase Update {
+ if Input.key() == ' ' {
+ for (position) in query [Position, {Player}] {
+ attach Shield on self() { amount: 3 }
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-despawn.md b/docs/language/ecs/kw-despawn.md
index 16c2df58..2ab74e20 100644
--- a/docs/language/ecs/kw-despawn.md
+++ b/docs/language/ecs/kw-despawn.md
@@ -5,8 +5,26 @@ category: ecs
kind: keyword
tokens: despawn
sig: despawn entity
-tip: Remove an entity.
+tip: Remove a model instance from the world, firing any @OnDespawn hooks.
order: 1
---
-Remove an entity. Inside a query, despawn self() removes the current match.
+A despawn removes a model instance from the world, freeing its slot and firing any `@OnDespawn` lifecycle hooks for its kind. The most common form is `despawn self()` inside a query loop, which removes the instance currently being visited. Because queries iterate lazily by ascending id rather than from a frozen snapshot, despawning the current instance — or one already visited — is safe and will not disturb the walk. Despawning is how the moving parts of a game leave: an enemy that reached zero health, a projectile that flew off screen, or every instance of a kind during a reset.
+
+```ludic
+program Cleanup {
+ property Position { column: int = 0, row: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+ model Enemy { Position, Health }
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 3, row: 3 }; Health { current: 0 } }
+ }
+
+ handler RemoveDefeated phase LateUpdate {
+ for (health) in query [Health, {Enemy}] {
+ if health.current <= 0 { despawn self() }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-detach.md b/docs/language/ecs/kw-detach.md
index 11b7f9e6..7b33cb1c 100644
--- a/docs/language/ecs/kw-detach.md
+++ b/docs/language/ecs/kw-detach.md
@@ -5,8 +5,26 @@ category: ecs
kind: keyword
tokens: detach
sig: detach Prop on entity
-tip: Remove a property from a live entity.
+tip: Structurally remove a property from a live model instance.
order: 7
---
-Remove a property from a live entity.
+detach removes a property from a live model instance, firing the property's `@OnDetach(Prop)` hook before the has-flag clears — so the hook can still read the outgoing field values as it tears down. It is the structural opposite of `attach`: where `disable` pauses a property but keeps its data for a later `enable`, `detach` genuinely removes the property, and a later `attach` re-seeds fresh fields from defaults. Detaching a property the instance does not have is a no-op. Reach for it when something is gone for good — a shield consumed, a status effect that has run its course, an ability removed.
+
+```ludic
+program ShieldBreak {
+ property Position { column: int = 0, row: int = 0 }
+ property Shield { amount: int = 0 }
+ model Player { Position, Shield }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 6, row: 6 }; Shield { amount: 1 } }
+ }
+
+ handler ConsumeShield phase Update {
+ for (shield) in query [Shield, {Player}] {
+ if shield.amount <= 0 { detach Shield on self() }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-disable.md b/docs/language/ecs/kw-disable.md
index 26159377..73e9c308 100644
--- a/docs/language/ecs/kw-disable.md
+++ b/docs/language/ecs/kw-disable.md
@@ -5,8 +5,26 @@ category: ecs
kind: keyword
tokens: disable
sig: disable Prop on entity
-tip: Deactivate a property without destroying its data.
+tip: Deactivate a property, model, or handler without destroying its data.
order: 5
---
-Deactivate a property without destroying its data. Also disable Model / a whole handler.
+disable switches something off while keeping it intact, so a later `enable` brings it back untouched — a reversible pause rather than a destruction. `disable Prop on instance` clears that instance's has-flag for the property, so queries stop matching it, but the field values stay in storage. It has three scopes: one property on one instance (`disable Shield on self()`), a whole model (`disable Enemy` drops all its instances out of every query), and a handler (`disable AiThink` stops it running each phase until re-enabled). Each toggle is a single flag flip, so nothing is copied or freed. Disabling a property fires any `@OnDisable(Prop)` hook. Contrast with `detach`, which structurally removes a property (a later `attach` re-seeds fresh fields).
+
+```ludic
+program Stealth {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Enemy { Position, Velocity }
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 4, row: 4 }; Velocity { delta_x: 1 } }
+ }
+
+ handler FreezeEnemies phase Update {
+ if Input.key() == 'p' {
+ for (velocity) in query [Velocity, {Enemy}] { disable Velocity on self() }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-enable.md b/docs/language/ecs/kw-enable.md
index 8200ad53..92ef9774 100644
--- a/docs/language/ecs/kw-enable.md
+++ b/docs/language/ecs/kw-enable.md
@@ -5,8 +5,27 @@ category: ecs
kind: keyword
tokens: enable
sig: enable Prop on entity
-tip: Re-activate a disabled property; its stored values are intact.
+tip: Re-activate a disabled property, model, or handler — its data is intact.
order: 4
---
-Re-activate a disabled property; its stored values are intact.
+enable reverses a disable: it turns something back on without recreating it. `enable Prop on instance` restores a property's has-flag so queries match the instance again, with the field values exactly as they were left — nothing was copied or freed, so re-enabling is cheap and lossless. There are three scopes, matching `disable`: a single property on one instance (`enable Shield on self()`), a whole model (`enable Enemy`, all its instances rejoin every query), and a handler (`enable AiThink`, it resumes running each phase). Re-enabling a property fires any `@OnEnable(Prop)` hook at the toggle point. Reach for enable/disable when you want to pause and resume, and for `attach`/`detach` when you want to add or structurally remove.
+
+```ludic
+program ShieldToggle {
+ property Position { column: int = 0, row: int = 0 }
+ property Shield { amount: int = 0 }
+ model Player { Position, Shield }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 8, row: 8 }; Shield { amount: 3 } }
+ disable Shield on self()
+ }
+
+ handler RestoreShield phase Update {
+ if Input.key() == ' ' {
+ for (position) in query [Position, {Player}] { enable Shield on self() }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-query.md b/docs/language/ecs/kw-query.md
index 9c4c7db3..70e883d7 100644
--- a/docs/language/ecs/kw-query.md
+++ b/docs/language/ecs/kw-query.md
@@ -5,12 +5,28 @@ category: ecs
kind: keyword
tokens: query
sig: query [PropA, PropB, {Tag}]
-tip: Match every entity that carries all listed properties.
+tip: Match every model instance carrying all the listed properties.
order: 2
---
-Match every entity that carries all listed properties. Use it in for (a, b) in query […]; a {Tag} filters without binding.
+A query selects every live model instance that carries all the listed properties, and is the core way handlers reach data. Use it in a `for (…) in query [...]` loop, where you write one loop variable per bound property in declaration order; each pass binds those properties for one matching instance and `self()` gives that instance. A `{Model}` term in tag position filters by model kind without binding a variable, so `query [Position, {Player}]` walks only players. Add a `where` clause to filter on field values (`where health.current > 0`). Matching is lazy and re-checked per instance as the loop reaches each id, not snapshotted, so spawns and despawns during the loop follow well-defined rules.
```ludic
-for (p, s) in query [Pos, Seg] { … }
+program Collisions {
+ property Position { column: int = 0, row: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+ model Enemy { Position, Health }
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 5, row: 5 }; Health { current: 10 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position, health) in query [Position, Health, {Enemy}] where health.current > 0 {
+ Screen.fill_rectangle(x: position.column * 16, y: position.row * 16, width: 16, height: 16, color: Color.Crimson)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/ecs/kw-self.md b/docs/language/ecs/kw-self.md
index 22d9bff3..716c168b 100644
--- a/docs/language/ecs/kw-self.md
+++ b/docs/language/ecs/kw-self.md
@@ -5,8 +5,26 @@ category: ecs
kind: keyword
tokens: self
sig: self()
-tip: The entity currently bound by the enclosing query.
+tip: The model instance currently bound by the enclosing query loop.
order: 3
---
-The entity currently bound by the enclosing query.
+self() yields the model instance currently being visited by the innermost enclosing `query` loop, as an `entity` handle. It is how a handler names "the instance this pass is about" — most often to `despawn self()`, to `attach`/`detach` a property `on self()`, or to pass the current instance to a function. Because it depends on the active query iteration, `self()` is only meaningful inside a `for (…) in query […]` loop (or a `@Queries` handler, which desugars to one). Outside any query it has no instance to refer to.
+
+```ludic
+program Reaper {
+ property Position { column: int = 0, row: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+ model Enemy { Position, Health }
+
+ handler Boot phase Start {
+ spawn Grunt { Position { column: 1, row: 1 }; Health { current: 0 } }
+ }
+
+ handler RemoveDefeated phase LateUpdate {
+ for (health) in query [Health, {Enemy}] {
+ if health.current <= 0 { despawn self() }
+ }
+ }
+}
+```
diff --git a/docs/language/ecs/kw-spawn.md b/docs/language/ecs/kw-spawn.md
index b91f2215..a2930de8 100644
--- a/docs/language/ecs/kw-spawn.md
+++ b/docs/language/ecs/kw-spawn.md
@@ -5,12 +5,22 @@ category: ecs
kind: keyword
tokens: spawn
sig: spawn Label { Prop { field: v }; … }
-tip: Create an entity carrying the listed properties (or a model).
+tip: Create a model instance carrying the listed properties, seeding their fields.
order: 0
---
-Create an entity carrying the listed properties (or a model). The label is for readability.
+A spawn creates a new model instance and attaches the listed properties, each field starting from its default and then taking any overrides you give. The `Label` after `spawn` is just a readable name for the instance at that call site — it does not have to be a declared model — while the `{Prop { … }; …}` block is what actually determines which properties the instance carries. If you `spawn` a declared `model`, every property in that model comes along automatically. A newly spawned instance is visible to matching queries immediately, and one spawned mid-loop at a higher id is even visited in the same tick, so spawn into a later phase if you want to defer that.
```ludic
-spawn Body { Seg { order: 1 }; Pos { x: 9, y: 7 } }
+program Waves {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+ model Enemy { Position, Velocity, Health }
+
+ handler SpawnWave phase Start {
+ spawn Grunt { Position { column: 18, row: 4 }; Velocity { delta_x: -1 }; Health { current: 20 } }
+ spawn Brute { Position { column: 18, row: 9 }; Velocity { delta_x: -1 }; Health { current: 60 } }
+ }
+}
```
diff --git a/docs/language/events/kw-cancel.md b/docs/language/events/kw-cancel.md
index f76f04cf..18591325 100644
--- a/docs/language/events/kw-cancel.md
+++ b/docs/language/events/kw-cancel.md
@@ -5,8 +5,34 @@ category: events
kind: keyword
tokens: cancel
sig: cancel
-tip: Inside a listener, veto a cancellable event.
+tip: Inside a listener, veto the cancellable event being emitted.
order: 2
---
-Inside a listener, veto a cancellable event.
+`cancel` is used inside an `@On` listener to veto the `cancellable` event currently being emitted. Calling it sets the payload's cancelled flag, and once the `emit` finishes running its listeners that flag comes back to the caller — `emit E(…)` used as an expression yields `1` when any listener cancelled and `0` otherwise. Guard it behind whatever condition should block the action; listeners that do not `cancel` simply observe. `cancel` is only meaningful in a listener for an event declared `cancellable`.
+
+```ludic
+program CancelExample {
+ event cancellable BeforeOpenDoor { key_count: int = 0 }
+
+ var keys: int = 0
+ var doors_opened: int = 0
+
+ @On(BeforeOpenDoor) handler RequireKey { if key_count <= 0 { cancel } }
+
+ handler TryOpen phase Update {
+ if Input.key() == 'o' {
+ if emit BeforeOpenDoor(key_count: keys) == 0 {
+ doors_opened = doors_opened + 1
+ keys = keys - 1
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 4, y: 4, value: doors_opened, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/events/kw-cancellable.md b/docs/language/events/kw-cancellable.md
new file mode 100644
index 00000000..f40beb5e
--- /dev/null
+++ b/docs/language/events/kw-cancellable.md
@@ -0,0 +1,34 @@
+---
+id: kw-cancellable
+name: cancellable
+category: events
+kind: keyword
+tokens: cancellable
+sig: event cancellable Name { … }
+tip: Marks an event whose listeners may veto it with cancel.
+order: 50
+---
+
+`cancellable` marks an event as a decision, not just a notification. A plain event tells listeners something happened; a `cancellable` event is fired before an action so a listener can veto it by calling `cancel`. The caller reads the verdict back by using `emit` as an expression: it yields `0` when no listener vetoed and `1` when one did, so the action is applied only on `0`. This is the Bukkit / DOM `preventDefault` shape — observation becomes control — and a foreign mod vetoes the same way by setting the payload's trailing cancelled flag over the ABI.
+
+```ludic
+program CancellableExample {
+ event cancellable BeforeHurt { amount: int = 0 }
+
+ var current_health: int = 100
+
+ @On(BeforeHurt) handler AbsorbSmallHits { if amount <= 5 { cancel } }
+
+ handler TakeDamage phase Update {
+ if Input.key() == 'h' {
+ if emit BeforeHurt(amount: 3) == 0 { current_health = current_health - 3 }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 4, y: 4, value: current_health, color: Color.Crimson, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/events/kw-emit.md b/docs/language/events/kw-emit.md
index 85c9b26a..b70ac33e 100644
--- a/docs/language/events/kw-emit.md
+++ b/docs/language/events/kw-emit.md
@@ -4,9 +4,31 @@ name: emit
category: events
kind: keyword
tokens: emit
-sig: emit Name(field: v)
-tip: Fire an event, invoking its listeners.
+sig: emit Name(field: value)
+tip: Fire an event, running every listener; as an expression it yields the veto flag.
order: 1
---
-Fire an event, invoking its listeners. As an expression it yields a cancellable event's cancelled flag.
+`emit Name(field: value, …)` fires an event: it runs every `@On(Name)` listener in declaration order, passing the named payload fields (any you omit take the event's declared defaults). It desugars to a direct call into the event's generated function, so firing an event is as cheap as calling a function. For a `cancellable` event, `emit` can be used as an expression that yields the cancelled flag — `0` if no listener vetoed, `1` if one did — which is the "check the decision before acting" shape: apply the effect only when the emit returns `0`.
+
+```ludic
+program EmitExample {
+ event cancellable BeforeSpend { amount: int = 0 }
+
+ var coins: int = 50
+
+ @On(BeforeSpend) handler RejectOverdraft { if amount > coins { cancel } }
+
+ handler TrySpend phase Update {
+ if Input.key() == 'b' {
+ if emit BeforeSpend(amount: 20) == 0 { coins = coins - 20 }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 4, y: 4, value: coins, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/events/kw-event.md b/docs/language/events/kw-event.md
index b22631f7..5673f334 100644
--- a/docs/language/events/kw-event.md
+++ b/docs/language/events/kw-event.md
@@ -5,8 +5,29 @@ category: events
kind: keyword
tokens: event
sig: event Name { field: T = default }
-tip: Declare a public event payload.
+tip: Declare a public event carrying a flat payload that listeners react to.
order: 0
---
-Declare a public event payload.
+`event` declares a public event — a named signal carrying a flat payload of typed fields (each with a default; the payload may be empty). Where lifecycle hooks are the closed reactions the author compiles in, events are the open runtime surface a game exposes to mods: an in-language listener registers with `@On(Name)` and reads the payload fields by name, and `emit Name(field: value, …)` fires every listener in declaration order as a direct call. It all desugars to a generated function — there is no interpreter and no dispatch table — and a program that declares no `event` compiles byte-for-byte as before. Mark an event `cancellable` when a listener should be able to veto the action it announces.
+
+```ludic
+program EventExample {
+ event EnemyDefeated { points: int = 0 }
+
+ var score: int = 0
+
+ @On(EnemyDefeated) handler AddScore { score = score + points }
+ @On(EnemyDefeated) handler PlayChime { Screen.status("enemy down") }
+
+ handler ScoreOnKey phase Update {
+ if Input.key() == 'k' { emit EnemyDefeated(points: 10) }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 4, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/input/input-key.md b/docs/language/input/input-key.md
index 7a3d6e77..a7f194cc 100644
--- a/docs/language/input/input-key.md
+++ b/docs/language/input/input-key.md
@@ -5,13 +5,46 @@ category: input
kind: namespace-method
tokens: Input.key
sig: Input.key() -> int
-tip: The key pressed this frame, as a character code (0 when nothing is pressed).
+tip: The key pressed this frame as a character code (0 when nothing is pressed).
order: 0
+ns: Input
+member: key
---
-The key pressed this frame, as a character code (0 when nothing is pressed). Compare against character literals like 'w'.
+Returns the key currently pressed this frame as a character code, or 0 when no key is down. Compare the result against character literals such as 'w', 'a', 's', 'd', or ' ' to drive movement and actions. Read it in an Input-phase handler so input is handled once per frame before the world updates. Because it reports the key held on this frame rather than a one-shot press event, a common pattern for "press to confirm" is to wait until Input.key() returns 0 (key released) before accepting the next press.
```ludic
-let k = Input.key()
-if k == 'w' { … }
+program MoveByKey {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ }
+
+ handler ReadInput phase Input {
+ let pressed = Input.key()
+ for (Position) in query [Position, {Player}] {
+ if pressed == 'w' { Position.row = Position.row - 1 }
+ if pressed == 's' { Position.row = Position.row + 1 }
+ if pressed == 'a' { Position.column = Position.column - 1 }
+ if pressed == 'd' { Position.column = Position.column + 1 }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/map/map-row.md b/docs/language/map/map-row.md
index a93cc3aa..cf2f360e 100644
--- a/docs/language/map/map-row.md
+++ b/docs/language/map/map-row.md
@@ -7,10 +7,46 @@ tokens: Map.row
sig: Map.row(y, cells)
tip: Fill one row of the tilemap from a string of tile characters.
order: 1
+ns: Map
+member: row
---
-Fill one row of the tilemap from a string of tile characters.
+Fills a single map row from a string, one character per cell — an easy, readable way to author a level directly in source. Character y selects the row (from the top, starting at 0), and each character of cells becomes the tile at that column. Call Map.size first, then one Map.row per row; the string length should match the map width. By convention '#' is a wall and '.' is open floor, but you may use any characters as your own tile codes and interpret them however you like with Map.tile.
+
+Parameters:
+- `y` — the row index to fill, from the top (`0`-based)
+- `cells` — a string of tile characters, one per column
```ludic
-Map.row(y: 0, cells: "####......####")
+program HandDrawnLevel {
+ const GRID_WIDTH: int = 10
+ const GRID_HEIGHT: int = 5
+ const TILE_SIZE: int = 16
+
+ handler BuildLevel phase Start {
+ Map.size(width: GRID_WIDTH, height: GRID_HEIGHT)
+ Map.row(y: 0, cells: "##########")
+ Map.row(y: 1, cells: "#........#")
+ Map.row(y: 2, cells: "#..####..#")
+ Map.row(y: 3, cells: "#........#")
+ Map.row(y: 4, cells: "##########")
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for row in 0 .. GRID_HEIGHT {
+ for column in 0 .. GRID_WIDTH {
+ if Map.tile(x: column, y: row) == '#' {
+ Screen.fill_rectangle(
+ x: column * TILE_SIZE,
+ y: row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.White)
+ }
+ }
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/map/map-size.md b/docs/language/map/map-size.md
index f2487586..4b327a68 100644
--- a/docs/language/map/map-size.md
+++ b/docs/language/map/map-size.md
@@ -5,8 +5,47 @@ category: map
kind: namespace-method
tokens: Map.size
sig: Map.size(width, height)
-tip: Set the tilemap dimensions in cells.
+tip: Set the tilemap dimensions in cells before filling rows.
order: 0
+ns: Map
+member: size
---
-Set the tilemap dimensions in cells.
+Declares the dimensions of the built-in tilemap, in cells (not pixels). Call it once during a Start-phase handler before you fill the map with Map.row, so the runtime knows how many columns and rows to allocate. The tilemap is a lightweight, character-based grid the whole program can read with Map.tile, which is handy for static level geometry such as walls and floors. Reads outside these bounds are treated as a wall character, so the edge of the world is solid for free.
+
+Parameters:
+- `width` — the map width in cells (columns)
+- `height` — the map height in cells (rows)
+
+```ludic
+program LevelBounds {
+ const GRID_WIDTH: int = 12
+ const GRID_HEIGHT: int = 4
+ const TILE_SIZE: int = 16
+
+ handler BuildLevel phase Start {
+ Map.size(width: GRID_WIDTH, height: GRID_HEIGHT)
+ Map.row(y: 0, cells: "############")
+ Map.row(y: 1, cells: "#..........#")
+ Map.row(y: 2, cells: "#..........#")
+ Map.row(y: 3, cells: "############")
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for row in 0 .. GRID_HEIGHT {
+ for column in 0 .. GRID_WIDTH {
+ if Map.tile(x: column, y: row) == '#' {
+ Screen.fill_rectangle(
+ x: column * TILE_SIZE,
+ y: row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.White)
+ }
+ }
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/map/map-tile.md b/docs/language/map/map-tile.md
index 96919691..9d1d5292 100644
--- a/docs/language/map/map-tile.md
+++ b/docs/language/map/map-tile.md
@@ -5,8 +5,54 @@ category: map
kind: namespace-method
tokens: Map.tile
sig: Map.tile(x, y) -> int
-tip: Read the tile character at a cell.
+tip: Read the tile character at a cell (out-of-bounds reads answer '#').
order: 2
+ns: Map
+member: tile
---
-Read the tile character at a cell. Out-of-bounds reads answer '#', so the edge of the world is a wall for free.
+Reads the tile character at cell (x, y) and returns it as a character code, so you can compare it against literals like '#' or '.'. Use it for collision checks (is the cell ahead a wall?), rendering the map, or querying your own custom tile codes. Cells outside the map you set with Map.size answer '#', so the border of the world acts as a wall automatically and you never need special edge-of-map handling. Coordinates are cell indices, not pixels.
+
+Parameters:
+- `x` — the cell column to read (`0`-based)
+- `y` — the cell row to read (`0`-based)
+
+```ludic
+program WallCollision {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler BuildLevel phase Start {
+ Map.size(width: 8, height: 4)
+ Map.row(y: 0, cells: "########")
+ Map.row(y: 1, cells: "#..##..#")
+ Map.row(y: 2, cells: "#......#")
+ Map.row(y: 3, cells: "########")
+ spawn Player { Position { column: 1, row: 2 } }
+ }
+
+ handler MovePlayer phase Update {
+ for (Position) in query [Position, {Player}] {
+ let next_column = Position.column + 1
+ if Map.tile(x: next_column, y: Position.row) != '#' {
+ Position.column = next_column
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/networking/fn-apply.md b/docs/language/networking/fn-apply.md
new file mode 100644
index 00000000..08728c6e
--- /dev/null
+++ b/docs/language/networking/fn-apply.md
@@ -0,0 +1,36 @@
+---
+id: fn-apply
+name: apply
+category: networking
+kind: builtin
+tokens: apply
+sig: apply(target, buf, len)
+tip: Fold received @Sync bytes back onto a model instance.
+order: 50
+---
+
+apply is the exact inverse of serialize: it reads len bytes from buf and copies each value back into the replicable-and-participating fields of the target model instance, in the same member-then-field order the serializer packed them. It is compiler-generated and dispatches on the instance's model, so it only touches synced fields — an unsynced field keeps whatever value it already held. Use it after net_poll to reconcile a client to the authority's state, or after a rollback to restore a snapshot you took with serialize. The buffer must have come from a serializer for a matching model, or the layout will not line up.
+
+Parameters:
+- `target` — the model instance (an `entity`) to write the fields onto
+- `buf` — the source buffer holding serialized bytes
+- `len` — the number of bytes in `buf` to read
+
+```ludic
+program ReconcilePlayer {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 3, row: 4 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ let snapshot_buffer = bytes(64)
+ let byte_count = serialize(player_id, snapshot_buffer) # save authoritative state
+ Position.column = 999 # a mispredicted divergence
+ apply(player_id, snapshot_buffer, byte_count) # reconcile back to the snapshot
+ print(Position.column) # 3 — restored
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-is_owner.md b/docs/language/networking/fn-is_owner.md
new file mode 100644
index 00000000..8b410563
--- /dev/null
+++ b/docs/language/networking/fn-is_owner.md
@@ -0,0 +1,33 @@
+---
+id: fn-is_owner
+name: is_owner
+category: networking
+kind: builtin
+tokens: is_owner
+sig: is_owner(target) -> bool
+tip: Whether the local peer owns a model instance.
+order: 50
+---
+
+is_owner reports whether the local peer owns a given model instance — it compares the instance's stored owner (see owner) against local_id and returns the result. It only makes sense for instances of an @Owned model, and it is what @Predicted handler dispatch consults to decide whether the owning client should run a control handler speculatively. Prefer this over reading owner and comparing ids yourself. A freshly spawned, unowned instance returns false everywhere until the authority assigns it with set_owner.
+
+Parameters:
+- `target` — the model instance (an `entity`) to test
+
+```ludic
+program CheckOwnership {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 5, row: 6 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ set_owner(player_id, 7)
+ print(is_owner(player_id)) # 0 — local id 0 does not own peer 7's instance
+ set_owner(player_id, 0)
+ print(is_owner(player_id)) # 1 — now the local peer owns it
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-is_server.md b/docs/language/networking/fn-is_server.md
index a2a7f924..eece8079 100644
--- a/docs/language/networking/fn-is_server.md
+++ b/docs/language/networking/fn-is_server.md
@@ -1,12 +1,28 @@
---
id: fn-is_server
-name: is_server / is_owner / local_id
+name: is_server
category: networking
kind: builtin
-tokens: is_server is_owner local_id
-sig: is_server() -> bool is_owner(e) -> bool local_id() -> int
-tip: Role and identity checks the runtime sets.
-order: 3
+tokens: is_server
+sig: is_server() -> bool
+tip: Whether this peer is the authority.
+order: 50
---
-The runtime sets the local role. is_server() is the authority check, is_owner(e) is true when this peer owns e, and local_id() is this peer's id. Prefer role annotations to sprinkling these through gameplay code.
+is_server returns whether the local peer is currently acting as the authority. The networking runtime sets the peer's role register (via set_role); offline it defaults to server, so an un-networked build reports true and every role guard collapses to "run here." This is the low-level read behind the @Server handler annotation — and in ordinary gameplay code you should reach for the annotation instead, since scattering is_server() branches through your simulation is exactly the readability footgun the role annotations exist to remove. Use the raw check only when you are driving the loop yourself.
+
+```ludic
+program AuthorityGate {
+ property Score { total: int = 0 }
+ model Scoreboard { Score }
+
+ @Server handler AwardPoints phase Update {
+ for (Score) in query [Score] { Score.total = Score.total + 10 }
+ }
+
+ entry {
+ spawn Scoreboard { Score { total: 0 } }
+ if is_server() { print(1) } else { print(0) } # 1 offline — defaults to authority
+ }
+}
+```
diff --git a/docs/language/networking/fn-local_id.md b/docs/language/networking/fn-local_id.md
new file mode 100644
index 00000000..af6ada7a
--- /dev/null
+++ b/docs/language/networking/fn-local_id.md
@@ -0,0 +1,28 @@
+---
+id: fn-local_id
+name: local_id
+category: networking
+kind: builtin
+tokens: local_id
+sig: local_id() -> int
+tip: This peer's own network id.
+order: 50
+---
+
+local_id returns the network id assigned to the local peer by the runtime. It is the value is_owner compares an instance's owner against, so it answers "which of these owned instances are mine?" when you drive ownership logic by hand. Offline, with no networking runtime spliced in, it defaults to 0. You rarely need it directly — is_owner already folds the comparison — but it is useful when tagging spawned instances or addressing a specific peer.
+
+```ludic
+program IdentifyPeer {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 0, row: 0 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ set_owner(player_id, local_id()) # claim this instance for the local peer
+ print(is_owner(player_id)) # 1 — owner now equals local_id()
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-net_poll.md b/docs/language/networking/fn-net_poll.md
index 32e9521e..a7e5ad64 100644
--- a/docs/language/networking/fn-net_poll.md
+++ b/docs/language/networking/fn-net_poll.md
@@ -9,4 +9,26 @@ tip: Receive queued bytes from the transport; returns the byte count.
order: 1
---
-Copy up to cap waiting bytes into buf and return how many arrived (0 when nothing is queued). Pair it with the generated apply to fold an update back into the world.
+net_poll copies up to cap waiting bytes into buf and returns how many actually arrived, or 0 when nothing is queued. It is the read half of the transport seam and the counterpart to net_send; each poll yields one datagram, so drain in a loop until it returns 0. Once you have the bytes you fold them back into a model instance with the generated apply — for a self-describing frame, write the entity id into the first word on send and read it back here before applying. Like net_send, this is the freedom layer beneath @Sync; most games never call it directly.
+
+Parameters:
+- `buf` — the destination buffer to copy received bytes into
+- `cap` — the maximum number of bytes to accept this call
+
+```ludic
+program ReceivePlayer {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 0, row: 0 } }
+ let incoming_bytes = words(512)
+ var received_count = net_poll(incoming_bytes, 2048)
+ while received_count > 0 {
+ let target_id = incoming_bytes[0] # entity id led the frame
+ apply(target_id, offset(incoming_bytes, 4), received_count - 4)
+ received_count = net_poll(incoming_bytes, 2048) # next datagram, if any
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-net_send.md b/docs/language/networking/fn-net_send.md
index 87b95771..a03582d3 100644
--- a/docs/language/networking/fn-net_send.md
+++ b/docs/language/networking/fn-net_send.md
@@ -5,8 +5,31 @@ category: networking
kind: builtin
tokens: net_send
sig: net_send(peer, buf, len)
-tip: Send bytes to a peer over the host's transport.
+tip: Send raw bytes to a peer over the host's transport seam.
order: 0
---
-Send len bytes from buf to peer. The transport itself is a host-provided seam — UDP natively, WebRTC/WebSocket on the web, or a loopback in tests — so the same game runs over any of them.
+net_send hands len bytes from buf to the numbered peer, and it is the write half of Ludic's transport seam. The transport itself is a host-provided seam rather than a fixed protocol — real UDP natively, WebRTC or WebSocket on the web, or a built-in loopback in tests — so the same game runs unchanged over any of them. This is the low-level freedom layer: you normally let @Sync and @Owned generate replication for you, and reach for net_send only when you drive the wire yourself, typically to ship a model instance's serialized @Sync fields. Pair it with serialize to fill the buffer and with net_poll on the far side to receive.
+
+Parameters:
+- `peer` — the destination peer id (a network id, e.g. `0` for the authority)
+- `buf` — the source buffer holding the bytes to send
+- `len` — how many bytes of `buf` to send
+
+```ludic
+program ReplicatePlayer {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 3, row: 4 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ let outgoing_bytes = words(512)
+ outgoing_bytes[0] = player_id # entity id in the first word
+ let field_count = serialize(player_id, offset(outgoing_bytes, 4))
+ net_send(0, outgoing_bytes, 4 + field_count) # ship id + synced fields
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-owner.md b/docs/language/networking/fn-owner.md
index 0cc0a801..14aa3d37 100644
--- a/docs/language/networking/fn-owner.md
+++ b/docs/language/networking/fn-owner.md
@@ -1,12 +1,32 @@
---
id: fn-owner
-name: owner / set_owner
+name: owner
category: networking
kind: builtin
-tokens: owner set_owner
-sig: owner(e) -> int set_owner(e, id)
-tip: Read or assign an entity's network owner.
-order: 2
+tokens: owner
+sig: owner(target) -> int
+tip: Read a model instance's network owner id.
+order: 50
---
-@Owned gives a model an owner slot; owner(e) reads the peer id that owns entity e and set_owner(e, id) assigns it (the authority decides). Ownership gates who may write @Sync(to: owner) fields.
+owner returns the network peer id that owns a model instance, or -1 when the instance is unowned. It only means anything for instances of an @Owned model, which is what gives the model its owner slot (the runtime's @L_owner array); a fresh instance starts unowned until the authority assigns it with set_owner. Ownership is part of the world snapshot, so it round-trips through replication and rollback. Read it when you need the raw id; use is_owner when you only need to know whether the local peer owns the instance.
+
+Parameters:
+- `target` — the model instance (an `entity`) whose owner is read
+
+```ludic
+program InspectOwner {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 5, row: 6 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ print(owner(player_id)) # -1 — a fresh instance is unowned
+ set_owner(player_id, 7)
+ print(owner(player_id)) # 7 — the authority assigned peer 7
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-serialize.md b/docs/language/networking/fn-serialize.md
index a5974867..76c4e4a7 100644
--- a/docs/language/networking/fn-serialize.md
+++ b/docs/language/networking/fn-serialize.md
@@ -1,12 +1,34 @@
---
id: fn-serialize
-name: serialize / apply
+name: serialize
category: networking
kind: builtin
-tokens: serialize apply
-sig: serialize(e, buf) -> int apply(e, buf, len)
-tip: Compiler-generated per-model replication of @Sync fields.
-order: 4
+tokens: serialize
+sig: serialize(target, buf) -> int
+tip: Pack a model instance's @Sync fields into a buffer; returns bytes written.
+order: 50
---
-Generated from the ECS schema per model: serialize writes an entity's @Sync fields into buf and returns the length; apply folds received bytes back onto an entity. Together they are the replication substrate the sugar builds on.
+serialize writes the replicated state of a model instance into buf and returns how many bytes it wrote. It is compiler-generated from the schema: it copies exactly the fields that are both @Sync-marked and participating in that instance's model, tightly packed in member-then-field order, and nothing else. This is the dispatcher over the per-model codecs, so it works for any spawned model whose fields sync. Use it to snapshot state before shipping it with net_send or before a rollback; the inverse is apply, which reads back the identical layout, and sync_size gives the byte count up front so you can size a buffer.
+
+Parameters:
+- `target` — the model instance (an `entity`) whose synced fields are read
+- `buf` — the destination buffer the packed bytes are written into
+
+```ludic
+program SnapshotPlayer {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ property Health { @Sync current: int = 0, maximum: int = 0 }
+ @Owned model Player { @Sync Position, @Sync Health }
+
+ entry {
+ spawn Player { Position { column: 3, row: 4 }, Health { current: 50, maximum: 100 } }
+ for (Position, Health) in query [Position, Health, {Player}] {
+ let player_id = self()
+ let snapshot_buffer = bytes(64)
+ let byte_count = serialize(player_id, snapshot_buffer) # pack column, row, current
+ print(byte_count) # 12 = Position(8) + Health.current(4)
+ }
+ }
+}
+```
diff --git a/docs/language/networking/fn-set_owner.md b/docs/language/networking/fn-set_owner.md
new file mode 100644
index 00000000..952b04d6
--- /dev/null
+++ b/docs/language/networking/fn-set_owner.md
@@ -0,0 +1,32 @@
+---
+id: fn-set_owner
+name: set_owner
+category: networking
+kind: builtin
+tokens: set_owner
+sig: set_owner(target, id)
+tip: Assign a model instance's network owner.
+order: 50
+---
+
+set_owner writes the owning peer id into a model instance's owner slot; it is how the authority hands an @Owned model instance to a client. Because ownership gates who may write @Sync(to: owner) fields and who runs @Predicted handlers, assignment is normally the server's job — a client assigning ownership to itself would defeat that. The value is stored in the world (the @L_owner array), so it survives snapshot, replication, and rollback. Pass the local peer id to make the local peer the owner, or any other peer's id to hand it off.
+
+Parameters:
+- `target` — the model instance (an `entity`) to assign
+- `id` — the owning peer id to store
+
+```ludic
+program AssignOwnership {
+ @Sync property Position { column: int = 0, row: int = 0 }
+ @Owned model Player { @Sync Position }
+
+ entry {
+ spawn Player { Position { column: 5, row: 6 } }
+ for (Position) in query [Position, {Player}] {
+ let player_id = self()
+ set_owner(player_id, 0) # hand this instance to the local peer (id 0)
+ print(is_owner(player_id)) # 1 — the local peer now owns it
+ }
+ }
+}
+```
diff --git a/docs/language/operators/op-access.md b/docs/language/operators/op-access.md
index 6d4b4205..20a86621 100644
--- a/docs/language/operators/op-access.md
+++ b/docs/language/operators/op-access.md
@@ -3,9 +3,15 @@ id: op-access
name: Member & index
category: operators
kind: operator
-sig: x.field buf[i] s[a..b]
-tip: Field/method access, element index, and string slice (a fresh substring).
+sig: x.field buffer[i] text[a..b]
+tip: Field access, element index, and string slice — usable as value or target.
order: 6
---
-Field/method access, element index, and string slice (a fresh substring).
+The access operators reach into a compound value. `value.field` reads or writes a property field and chains freely (`route.next.column`); `buffer[index]` reads or writes one element of a slice or raw buffer; and `text[start..end]` produces a fresh substring of the bytes in the half-open range, while `text[index]` reads a single byte as an `int` code point. Each form works both as a value and as an assignment target, and they compose — `route[index].column = 0` is one address computation. Field access also binds method-style calls like `Screen.fill_rectangle`.
+
+```ludic
+let head_column: int = segments[0].column
+segments[0].column = head_column + 1
+let extension: str = file_name[len(file_name) - 4 .. len(file_name)]
+```
diff --git a/docs/language/operators/op-arith.md b/docs/language/operators/op-arith.md
index e54141c1..0b10a59d 100644
--- a/docs/language/operators/op-arith.md
+++ b/docs/language/operators/op-arith.md
@@ -4,8 +4,14 @@ name: Arithmetic
category: operators
kind: operator
sig: + - * / %
-tip: Add, subtract, multiply, integer-divide, remainder.
+tip: Add, subtract, multiply, integer-divide, and remainder — on int and fixed.
order: 0
---
-Add, subtract, multiply, integer-divide, remainder. On fixed values the same symbols do fixed-point math.
+The arithmetic operators are `+` (add), `-` (subtract), `*` (multiply), `/` (divide), and `%` (remainder). On `int` values `/` is integer division that truncates and `%` gives the remainder — handy for wrapping a value or testing a period, as in `elapsed_frames % 30 == 0`. On `fixed` values the same symbols do fixed-point math (multiply and divide are scaled), and mixing an `int` with a `fixed` promotes the `int`. `*`, `/`, and `%` bind tighter than `+` and `-`, so `column * TILE_SIZE + 1` groups as expected.
+
+```ludic
+let pixel_x: int = column * TILE_SIZE + 1
+let is_even_row: bool = row % 2 == 0
+let average: int = (current_health + maximum_health) / 2
+```
diff --git a/docs/language/operators/op-assign.md b/docs/language/operators/op-assign.md
index 99be2a6d..66e9729b 100644
--- a/docs/language/operators/op-assign.md
+++ b/docs/language/operators/op-assign.md
@@ -4,8 +4,16 @@ name: Assignment
category: operators
kind: operator
sig: name = value
-tip: Assign to a var, a field, or an element.
+tip: Store into a var, a field, or an element — a statement, not an expression.
order: 5
---
-Assign to a var, a field, or an element. Not an expression.
+Assignment stores a value into a target — a `var` binding, a property field, or a buffer/slice element — with `target = value`. The compound forms `+=`, `-=`, `*=`, and `/=` update in place, so `score += 1` means `score = score + 1`. Assignment is a statement, not an expression: it produces no value, so you cannot write `if x = 0` (use `==` for the test). Only a mutable target accepts it — assigning to a `let` or `const` binding is a compile error — though you may still mutate through an immutable binding that holds a record or slice.
+
+```ludic
+var score: int = 0
+score = 10
+score += 5
+let current_position = new_position()
+current_position.column = current_position.column + 1
+```
diff --git a/docs/language/operators/op-bitwise.md b/docs/language/operators/op-bitwise.md
index 293fe2cb..d4847d24 100644
--- a/docs/language/operators/op-bitwise.md
+++ b/docs/language/operators/op-bitwise.md
@@ -4,8 +4,15 @@ name: Bitwise
category: operators
kind: operator
sig: & | ^ ~ << >>
-tip: And, or, xor, not, shift left/right.
+tip: Bit-level and, or, xor, not, and shifts — with Go-style precedence.
order: 3
---
-And, or, xor, not, shift left/right. Shifts and & bind like *; |/^ bind like + — tighter than comparison, so flags & MASK == 0 needs no parentheses.
+The bitwise operators work on the bits of an `int`: `&` (and), `|` (or), `^` (xor), `~` (not), `<<` (shift left), and `>>` (a logical/unsigned shift right). They are the tools for flag sets, packing several small values into one integer, and fast multiply/divide by powers of two. Precedence is Go-style: `<<`, `>>`, and `&` bind like `*` (tightly), while `|` and `^` bind like `+`, and all of them bind tighter than comparison — so `flags & MASK == 0` means `(flags & MASK) == 0` with no parentheses.
+
+```ludic
+const FLAG_POISONED: int = 1
+const FLAG_SHIELDED: int = 2
+var status_flags: int = FLAG_POISONED | FLAG_SHIELDED
+var is_shielded: bool = status_flags & FLAG_SHIELDED != 0
+```
diff --git a/docs/language/operators/op-comment.md b/docs/language/operators/op-comment.md
index b3750804..138d0329 100644
--- a/docs/language/operators/op-comment.md
+++ b/docs/language/operators/op-comment.md
@@ -4,8 +4,14 @@ name: Comment
category: operators
kind: operator
sig: # to end of line
-tip: Everything after # on a line is a comment.
+tip: Everything after # on a line is a comment, ignored by the compiler.
order: 9
---
-Everything after # on a line is a comment.
+A comment begins with `#` and runs to the end of the line; the compiler ignores everything after it. Ludic has only this one line-comment form — there is no block-comment syntax — so to comment out several lines put a `#` on each. Use comments to explain intent next to a `const`, to label a section of a handler, or to note a gotcha. The source formatter (`ludic-fmt`) works on tokens, so your comments and blank lines survive a reformat.
+
+```ludic
+const TILE_SIZE: int = 16 # pixels per grid cell
+var score: int = 0 # reset in the Start phase
+# the head is drawn brighter than the body
+```
diff --git a/docs/language/operators/op-compare.md b/docs/language/operators/op-compare.md
index b9da4eda..f780ec00 100644
--- a/docs/language/operators/op-compare.md
+++ b/docs/language/operators/op-compare.md
@@ -4,8 +4,13 @@ name: Comparison
category: operators
kind: operator
sig: == != < <= > >=
-tip: Yield a bool.
+tip: Compare two values and yield a bool; on strings == compares contents.
order: 1
---
-Yield a bool. On strings, == compares contents.
+The comparison operators — `==` (equal), `!=` (not equal), `<`, `<=`, `>`, `>=` — take two values and yield a `bool`, the kind of test an `if` or `where` clause wants. On `str` values `==` and `!=` compare by content, so `direction == "left"` checks the characters, not the pointer (a comparison against `null` stays a pointer test). Comparison binds looser than arithmetic and looser than the bitwise operators, so `flags & MASK == 0` reads as `(flags & MASK) == 0` with no parentheses needed. Chain several conditions together with `and` / `or`.
+
+```ludic
+if current_health <= 0 { become GameOver }
+if pressed_key == 'w' and elapsed_frames > 0 { row = row - 1 }
+```
diff --git a/docs/language/operators/op-interp.md b/docs/language/operators/op-interp.md
index c7180324..17210529 100644
--- a/docs/language/operators/op-interp.md
+++ b/docs/language/operators/op-interp.md
@@ -8,8 +8,10 @@ tip: A backtick string with {expr} holes, each stringified and concatenated.
order: 7
---
-A backtick string with {expr} holes, each stringified and concatenated. {{ and }} are literal braces.
+A backtick string `` `…` `` is an interpolated string: any `{expr}` hole inside it is evaluated, converted to text, and concatenated with the surrounding literal parts. Numbers, `bool`s, and `fixed` values are stringified automatically and `str` values pass through, so `` `score: {score}` `` desugars to `"score: " + str(score)`. It is the readable way to build a message from mixed pieces without hand-writing a `+` chain. Write a literal brace with `{{` or `}}`.
```ludic
-print(`score: {score}`)
+let status_line: str = `score {score} — health {current_health}/{maximum_health}`
+Screen.status(status_line)
+print(`wave {wave_number} incoming`)
```
diff --git a/docs/language/operators/op-literals.md b/docs/language/operators/op-literals.md
index cb6bc468..fe5e7297 100644
--- a/docs/language/operators/op-literals.md
+++ b/docs/language/operators/op-literals.md
@@ -4,8 +4,15 @@ name: Literals
category: operators
kind: operator
sig: 42 0x1E90FF 'w' "text" true null
-tip: Decimal and hex ints (hex is how colors are written), a character code in single quotes, a string in double quotes, booleans, and the null pointer.
+tip: Integer, hex, character, string, boolean, and null-pointer literals.
order: 8
---
-Decimal and hex ints (hex is how colors are written), a character code in single quotes, a string in double quotes, booleans, and the null pointer.
+Literals are the fixed values you write directly in source. `42` is a decimal `int` and `0x1E90FF` is a hex `int` — hex is how colors are written, so a raw color is just an integer. A number with a decimal point (`1.5`) is a `fixed`. `'w'` is a character literal, an `int` code point handy for comparing against `Input.key()`. `"text"` is a `str`, `true` / `false` are `bool`s, and `null` is the null-pointer literal used to test an unset record, slice, or `ptr` field.
+
+```ludic
+let sky_color: int = 0x1E90FF
+let banner: str = "GAME OVER"
+if Input.key() == 'w' { row = row - 1 }
+if next_segment == null { is_tail = true }
+```
diff --git a/docs/language/operators/op-logical.md b/docs/language/operators/op-logical.md
index 63e392be..24533c7e 100644
--- a/docs/language/operators/op-logical.md
+++ b/docs/language/operators/op-logical.md
@@ -4,12 +4,13 @@ name: Logical
category: operators
kind: operator
sig: and or not
-tip: Boolean combinators — words, not symbols.
+tip: The boolean combinators, spelled as words — never && or || or a bare !.
order: 2
---
-Boolean combinators — words, not symbols.
+The boolean combinators are `and`, `or`, and `not`. They combine `bool` values — usually the results of comparisons — into the compound conditions an `if`, `while`, or `where` clause tests. Ludic spells them as words, not symbols: `&&` and `||` are not operators, and a bare `!` is rejected with a diagnostic pointing you to `not` (`!=` is unaffected). `and` binds tighter than `or`, so `a and b or c` groups as `(a and b) or c`; add parentheses when you mean otherwise.
```ludic
-if k != 0 and mode == 0 { … }
+if pressed_key != 0 and not is_paused { apply_input() }
+if current_health <= 0 or elapsed_frames > time_limit { become GameOver }
```
diff --git a/docs/language/operators/op-range.md b/docs/language/operators/op-range.md
index 9b55b1df..d1166501 100644
--- a/docs/language/operators/op-range.md
+++ b/docs/language/operators/op-range.md
@@ -4,8 +4,16 @@ name: Range
category: operators
kind: operator
sig: a .. b
-tip: A half-open range for for loops: a up to but not including b.
+tip: A half-open range for numeric for loops — from a up to but not including b.
order: 4
---
-A half-open range for for loops: a up to but not including b.
+`a .. b` is a half-open numeric range used by the counting `for` loop: `for index in a .. b` walks `index` from `a` up to but not including `b`. This "up to but not including" convention makes lengths line up naturally — `0 .. len(route)` visits every element index of a slice, and `0 .. GRID_WIDTH` visits every column with no off-by-one. Both endpoints are `int` expressions, so a bound can be a `const`, a `var`, or any computed value. It is distinct from the string slice `text[start..end]`, which uses the same half-open idea for bytes.
+
+```ludic
+for row in 0 .. GRID_HEIGHT {
+ for column in 0 .. GRID_WIDTH {
+ Screen.put_pixel(x: column, y: row, color: Color.MidnightBlue)
+ }
+}
+```
diff --git a/docs/language/phases/phase-fixedupdate.md b/docs/language/phases/phase-fixedupdate.md
new file mode 100644
index 00000000..9f09236c
--- /dev/null
+++ b/docs/language/phases/phase-fixedupdate.md
@@ -0,0 +1,40 @@
+---
+id: phase-fixedupdate
+name: FixedUpdate
+category: phases
+kind: phase
+tokens: FixedUpdate
+sig: phase FixedUpdate
+tip: The deterministic simulation step — physics and gameplay meant to be reproducible.
+order: 2
+---
+
+`FixedUpdate` runs each frame after `Input` and before `Update`. It is the home for the deterministic core of the simulation — movement, collisions, physics — the logic you want to produce the identical result given the same inputs and RNG seed. The runtime groups `Input`, `FixedUpdate`, `Update`, and `LateUpdate` into the "sim" step (exposed together for replay, rollback, and headless tests), while `Render` is kept separate so drawing never affects the outcome. Put anything that must stay reproducible here and mark the handler `@deterministic`; put frame-dressing and non-critical logic in `Update` instead.
+
+```ludic
+program FixedUpdateExample {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Projectile { Position, Velocity }
+
+ handler SpawnShot phase Start {
+ spawn Projectile { Position { column: 0, row: 8 }; Velocity { delta_x: 1, delta_y: 0 } }
+ }
+
+ @deterministic
+ handler AdvancePositions phase FixedUpdate {
+ for (current_position, current_velocity) in query [Position, Velocity] {
+ current_position.column = current_position.column + current_velocity.delta_x
+ current_position.row = current_position.row + current_velocity.delta_y
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (current_position) in query [Position, {Projectile}] {
+ Screen.fill_rectangle(x: current_position.column * 16, y: current_position.row * 16, width: 16, height: 16, color: Color.Crimson)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/phases/phase-input.md b/docs/language/phases/phase-input.md
new file mode 100644
index 00000000..d4c7919c
--- /dev/null
+++ b/docs/language/phases/phase-input.md
@@ -0,0 +1,34 @@
+---
+id: phase-input
+name: Input
+category: phases
+kind: phase
+tokens: Input
+sig: phase Input
+tip: The first per-frame phase — read the keyboard and record the player's intent.
+order: 1
+---
+
+`Input` is the first phase of every frame. The runtime polls the keyboard just before it runs, so a `handler … phase Input` reads the current key with `Input.key()` and turns it into intent — a chosen direction, a menu selection, a pause toggle — usually by writing a program `var` that later phases act on. Keep it to reading input and recording decisions: do the actual movement and simulation in `FixedUpdate`/`Update`, and the drawing in `Render`. Because it runs before `FixedUpdate`, `Update`, `LateUpdate`, and `Render`, the intent it records is visible to every phase in the same frame.
+
+```ludic
+program InputExample {
+ const DIRECTION_LEFT: int = 0
+ const DIRECTION_RIGHT: int = 1
+ var facing: int = DIRECTION_RIGHT
+
+ handler ReadControls phase Input {
+ let pressed_key = Input.key()
+ if pressed_key == 'a' { facing = DIRECTION_LEFT }
+ if pressed_key == 'd' { facing = DIRECTION_RIGHT }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ var marker_x = 40
+ if facing == DIRECTION_RIGHT { marker_x = 200 }
+ Screen.fill_rectangle(x: marker_x, y: 100, width: 16, height: 16, color: Color.LimeGreen)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/phases/phase-lateupdate.md b/docs/language/phases/phase-lateupdate.md
new file mode 100644
index 00000000..332ff390
--- /dev/null
+++ b/docs/language/phases/phase-lateupdate.md
@@ -0,0 +1,36 @@
+---
+id: phase-lateupdate
+name: LateUpdate
+category: phases
+kind: phase
+tokens: LateUpdate
+sig: phase LateUpdate
+tip: Runs each frame after Update and before Render.
+order: 4
+---
+
+LateUpdate runs once per frame, after every Update handler has finished and just before Render. Use it for logic that must observe the settled result of this frame's updates: following the camera to the player's new position, resolving queued despawns, or any end-of-step cleanup that would be wrong to do while other Update handlers are still moving models. The full per-frame order the engine runs is Input → FixedUpdate → Update → LateUpdate → Render, with Start running once at boot before any of them.
+
+```ludic
+program FollowCamera {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ var camera_column: int = 0
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 12, row: 0 } }
+ }
+
+ @Queries(these: [Position])
+ handler MovePlayer phase Update {
+ Position.column = Position.column + 1
+ }
+
+ # runs after MovePlayer, so the camera sees the final position
+ @Queries(these: [Position])
+ handler TrackPlayer phase LateUpdate {
+ camera_column = Position.column
+ }
+}
+```
diff --git a/docs/language/phases/phase-render.md b/docs/language/phases/phase-render.md
new file mode 100644
index 00000000..37d04b79
--- /dev/null
+++ b/docs/language/phases/phase-render.md
@@ -0,0 +1,39 @@
+---
+id: phase-render
+name: Render
+category: phases
+kind: phase
+tokens: Render
+sig: phase Render
+tip: The last per-frame phase — draw the world, then call Screen.show() once.
+order: 5
+---
+
+`Render` is the final phase of every frame, run after all the logic phases (`Input`, `FixedUpdate`, `Update`, `LateUpdate`) have finished. A `handler … phase Render` paints the frame with the `Screen.*` calls — start by clearing the surface with `Screen.clear`, draw everything back-to-front, and finish with a single `Screen.show()` to present the completed frame to the window. Keep `Render` free of game-state changes: it should read the world and draw it, not move things, so that the drawing never affects the simulation. When you use scenes, layers within a scene render in declaration order, so a HUD layer written after a world layer paints on top of it.
+
+```ludic
+program RenderExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+ var score: int = 0
+
+ handler PlacePlayer phase Start {
+ spawn Player { Position { column: 5, row: 6 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (current_position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: current_position.column * TILE_SIZE,
+ y: current_position.row * TILE_SIZE,
+ width: TILE_SIZE, height: TILE_SIZE, color: Color.LimeGreen)
+ }
+ Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+ Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/phases/phase-start.md b/docs/language/phases/phase-start.md
new file mode 100644
index 00000000..6357100a
--- /dev/null
+++ b/docs/language/phases/phase-start.md
@@ -0,0 +1,35 @@
+---
+id: phase-start
+name: Start
+category: phases
+kind: phase
+tokens: Start
+sig: phase Start
+tip: Runs once at boot, before the game loop — the place to set up initial state.
+order: 0
+---
+
+`Start` is the boot phase: a `handler … phase Start` runs exactly once, before the first frame and before the per-frame loop begins. Use it to build the world that everything else assumes — seed the RNG with `Random.seed`, set the tilemap size with `Map.size`, spawn the starting models, and initialise your program `var`s. It runs before any scene is entered, so it is the one phase a scene `layer` handler may not use; per-scene setup belongs in a scene's `on enter` block instead. Every other phase (`Input`, `FixedUpdate`, `Update`, `LateUpdate`, `Render`) then repeats each frame, but `Start` never runs again.
+
+```ludic
+program StartExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const GRID_WIDTH: int = 20
+ var score: int = 0
+
+ handler SetUpWorld phase Start {
+ Random.seed(value: 1)
+ Map.size(width: GRID_WIDTH, height: 15)
+ score = 0
+ spawn Player { Position { column: 10, row: 7 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 4, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/phases/phase-update.md b/docs/language/phases/phase-update.md
new file mode 100644
index 00000000..8548ff66
--- /dev/null
+++ b/docs/language/phases/phase-update.md
@@ -0,0 +1,41 @@
+---
+id: phase-update
+name: Update
+category: phases
+kind: phase
+tokens: Update
+sig: phase Update
+tip: The ordinary per-frame game-logic step, run after Input and FixedUpdate.
+order: 3
+---
+
+`Update` is the everyday game-logic phase, run each frame after `Input` and `FixedUpdate` and before `LateUpdate` and `Render`. It is where most gameplay lives when you are not being strict about determinism: spawn waves on a timer, apply the intent that `Input` recorded, check win/lose conditions, advance animations. Reach for `Update` by default and reserve `FixedUpdate` for the simulation you specifically want reproducible. Because it runs after `Input`, the current frame's key press is already reflected in your program `var`s by the time `Update` reads them.
+
+```ludic
+program UpdateExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ const GRID_WIDTH: int = 20
+ var elapsed_frames: int = 0
+ var score: int = 0
+
+ handler SpawnWave phase Update {
+ elapsed_frames = elapsed_frames + 1
+ if elapsed_frames % 30 == 0 {
+ let spawn_column = Random.range(low: 0, high: GRID_WIDTH - 1)
+ spawn Enemy { Position { column: spawn_column, row: 0 } }
+ score = score + 1
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (current_position) in query [Position, {Enemy}] {
+ Screen.fill_rectangle(x: current_position.column * 16, y: current_position.row * 16, width: 16, height: 16, color: Color.Crimson)
+ }
+ Screen.draw_number(x: 4, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/random/random-chance.md b/docs/language/random/random-chance.md
index 391935ae..f0d6283c 100644
--- a/docs/language/random/random-chance.md
+++ b/docs/language/random/random-chance.md
@@ -5,8 +5,48 @@ category: random
kind: namespace-method
tokens: Random.chance
sig: Random.chance(percent) -> bool
-tip: True with the given percent probability.
+tip: Return true with the given percent probability.
order: 1
+ns: Random
+member: chance
---
-True with the given percent probability.
+Returns true roughly percent times out of a hundred, and false otherwise — a convenient shorthand for "this happens X% of the time". Use it to gate random events such as spawning an enemy on a given frame, dropping loot, or triggering a rare animation. Passing 0 never fires and 100 always fires. Like the rest of the Random namespace it is driven by the seeded generator, so seed it with Random.seed for reproducible behavior.
+
+Parameters:
+- `percent` — the probability of returning `true`, from `0` to `100`
+
+```ludic
+program SpawnWave {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ const GRID_WIDTH: int = 20
+ const TILE_SIZE: int = 16
+
+ handler SeedRandom phase Start {
+ Random.seed(value: 3)
+ }
+
+ handler MaybeSpawn phase Update {
+ if Random.chance(percent: 10) {
+ spawn Enemy {
+ Position { column: Random.range(low: 0, high: GRID_WIDTH - 1), row: 0 }
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Enemy}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.Crimson)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/random/random-range.md b/docs/language/random/random-range.md
index 4015e332..b376ada0 100644
--- a/docs/language/random/random-range.md
+++ b/docs/language/random/random-range.md
@@ -7,10 +7,48 @@ tokens: Random.range
sig: Random.range(low, high) -> int
tip: A random integer in the inclusive range [low, high].
order: 0
+ns: Random
+member: range
---
-A random integer in the inclusive range [low, high].
+Returns a random integer between low and high, inclusive of both ends — so Random.range(low: 0, high: 3) can return 0, 1, 2, or 3. Use it to place things randomly on a grid, pick from a list of options by index, or add jitter to positions and timers. The sequence is deterministic for a given seed, so call Random.seed first if you need reproducible runs. To pick a random column across a grid of width GRID_WIDTH, pass high: GRID_WIDTH - 1, since the last valid index is one less than the count.
+
+Parameters:
+- `low` — the smallest value that can be returned (inclusive)
+- `high` — the largest value that can be returned (inclusive)
```ludic
-food_x = Random.range(low: 0, high: GRID_W - 1)
+program ScatterPickups {
+ property Position { column: int = 0, row: int = 0 }
+ model Pickup { Position }
+
+ const GRID_WIDTH: int = 20
+ const GRID_HEIGHT: int = 15
+ const TILE_SIZE: int = 16
+
+ handler SeedPickups phase Start {
+ Random.seed(value: 7)
+ for spawn_index in 0 .. 5 {
+ spawn Pickup {
+ Position {
+ column: Random.range(low: 0, high: GRID_WIDTH - 1),
+ row: Random.range(low: 0, high: GRID_HEIGHT - 1)
+ }
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Pickup}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.Gold)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/random/random-seed.md b/docs/language/random/random-seed.md
index ddf87af1..9fdcbbc3 100644
--- a/docs/language/random/random-seed.md
+++ b/docs/language/random/random-seed.md
@@ -5,8 +5,49 @@ category: random
kind: namespace-method
tokens: Random.seed
sig: Random.seed(value)
-tip: Seed the RNG.
+tip: Seed the random generator so runs are reproducible.
order: 2
+ns: Random
+member: seed
---
-Seed the RNG. Seeding with the same value makes runs reproducible.
+Sets the starting state of the random-number generator. Every subsequent call to Random.range and Random.chance then follows a fixed, repeatable sequence for that seed, so seeding with the same value makes two runs produce identical randomness — invaluable for tests, replays, and debugging. Seed once, typically in a Start-phase handler before you draw any random numbers. Use different seed values (for example a frame count or a level number) when you want variety between runs.
+
+Parameters:
+- `value` — the seed; the same value reproduces the same sequence
+
+```ludic
+program ReproducibleField {
+ property Position { column: int = 0, row: int = 0 }
+ model Star { Position }
+
+ const GRID_WIDTH: int = 20
+ const GRID_HEIGHT: int = 15
+ const TILE_SIZE: int = 16
+
+ handler SeedField phase Start {
+ Random.seed(value: 42)
+ for spawn_index in 0 .. 8 {
+ spawn Star {
+ Position {
+ column: Random.range(low: 0, high: GRID_WIDTH - 1),
+ row: Random.range(low: 0, high: GRID_HEIGHT - 1)
+ }
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Star}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.White)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/scenes/kw-enter.md b/docs/language/scenes/kw-enter.md
index f7abdcb1..66051c11 100644
--- a/docs/language/scenes/kw-enter.md
+++ b/docs/language/scenes/kw-enter.md
@@ -5,8 +5,33 @@ category: scenes
kind: keyword
tokens: enter
sig: on enter { … }
-tip: The scene-entry hook.
+tip: The scene-entry hook — runs once as a scene becomes active.
order: 3
---
-The scene-entry hook.
+`enter` names the body of a scene's entry hook, written `on enter { … }`. It runs once each time the scene becomes active — at boot for the `start` scene (right after the `Start` phase), and on every `become Name` that switches into it. This is where a scene builds what it owns: spawn its models, reset its counters, set the tilemap, open a menu. It is a lifecycle hook, not a phase, so it may not contain `phase` handlers; the scene's ongoing behaviour goes in its `layer` handlers instead.
+
+```ludic
+program EnterHookExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ var score: int = 0
+
+ scene Overworld start {
+ on enter {
+ score = 0
+ spawn Player { Position { column: 5, row: 5 } }
+ }
+ layer World {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (current_position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: current_position.column * 16, y: current_position.row * 16, width: 16, height: 16, color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/scenes/kw-exit.md b/docs/language/scenes/kw-exit.md
index ef41b48f..2e7501ac 100644
--- a/docs/language/scenes/kw-exit.md
+++ b/docs/language/scenes/kw-exit.md
@@ -5,8 +5,40 @@ category: scenes
kind: keyword
tokens: exit
sig: on exit { … }
-tip: The scene-exit hook.
+tip: The scene-exit hook — runs once as a scene is left.
order: 4
---
-The scene-exit hook.
+`exit` names the body of a scene's exit hook, written `on exit { … }`. It runs once when the scene is left — on a `become Other`, the leaving scene's `on exit` runs first, then the active scene switches and the new scene's `on enter` runs. Use it to tear down what the scene created so it does not leak into the next state: despawn the scene's models, hide a menu, save progress. Like `on enter`, it is a lifecycle hook rather than a phase and holds plain statements, not `phase` handlers.
+
+```ludic
+program ExitHookExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ scene Battle start {
+ on enter { spawn Enemy { Position { column: 8, row: 4 } } }
+ on exit {
+ for (any_position) in query [Position, {Enemy}] { despawn self() }
+ }
+ layer Fight {
+ handler LeaveBattle phase Update {
+ if Input.key() == 'q' { become Overworld }
+ }
+ handler DrawBattle phase Render {
+ Screen.clear(Color.Crimson)
+ Screen.show()
+ }
+ }
+ }
+
+ scene Overworld {
+ layer World {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.show()
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/scenes/kw-layer.md b/docs/language/scenes/kw-layer.md
index b4ec299f..ce303efb 100644
--- a/docs/language/scenes/kw-layer.md
+++ b/docs/language/scenes/kw-layer.md
@@ -5,8 +5,38 @@ category: scenes
kind: keyword
tokens: layer
sig: layer Name { handlers }
-tip: A group of handlers inside a scene; layers render in declaration order.
+tip: A named group of handlers inside a scene; layers render in declaration order.
order: 1
---
-A group of handlers inside a scene; layers render in declaration order.
+A `layer` groups handlers inside a scene, and declaration order is draw order: within a phase the active scene's layers run in the order they were written, so a `Hud` layer declared after a `World` layer paints its `Render` output on top. Layers are the natural unit for stacking a heads-up display over the play-field, or a menu over a paused world. A layer handler may use any phase except `Start` (boot-time setup belongs in the scene's `on enter`). Handlers that should run in every scene are declared outside any scene rather than in a layer.
+
+```ludic
+program LayerExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ var score: int = 0
+
+ scene Overworld start {
+ on enter { spawn Player { Position { column: 6, row: 6 } } }
+
+ layer World {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (current_position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: current_position.column * 16, y: current_position.row * 16, width: 16, height: 16, color: Color.LimeGreen)
+ }
+ }
+ }
+
+ layer Hud {
+ handler DrawScore phase Render {
+ Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+ Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show() # the last layer to draw presents the frame
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/scenes/kw-on.md b/docs/language/scenes/kw-on.md
index 5853ed20..7425b143 100644
--- a/docs/language/scenes/kw-on.md
+++ b/docs/language/scenes/kw-on.md
@@ -5,8 +5,48 @@ category: scenes
kind: keyword
tokens: on
sig: on enter { … } on exit { … }
-tip: Hooks that fire when a scene becomes active or is left.
+tip: Scene lifecycle hooks that fire as a scene becomes active or is left.
order: 2
---
-Hooks that fire when a scene becomes active or is left.
+`on` introduces a scene's lifecycle hooks: `on enter { … }` runs once when the scene becomes active, and `on exit { … }` runs once as it is left. They are hooks, not phases — the place for per-scene setup and teardown that has no equivalent in a mode register. The start scene's `on enter` fires at boot right after the `Start` phase; on a `become Other` the current scene's `on exit` runs, the active scene switches, then the new scene's `on enter` runs. Use `on enter` to spawn the models a scene needs and reset its counters, and `on exit` to tidy up before leaving.
+
+```ludic
+program SceneHookExample {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ var elapsed_frames: int = 0
+
+ scene Overworld start {
+ layer World {
+ handler EnterBattle phase Update {
+ if Input.key() == 'b' { become Battle }
+ }
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.show()
+ }
+ }
+ }
+
+ scene Battle {
+ on enter {
+ elapsed_frames = 0
+ spawn Enemy { Position { column: 8, row: 4 } }
+ }
+ on exit {
+ for (any_position) in query [Position, {Enemy}] { despawn self() }
+ }
+ layer Fight {
+ handler DrawBattle phase Render {
+ Screen.clear(Color.Crimson)
+ for (current_position) in query [Position, {Enemy}] {
+ Screen.fill_rectangle(x: current_position.column * 16, y: current_position.row * 16, width: 16, height: 16, color: Color.White)
+ }
+ Screen.show()
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/scenes/kw-scene.md b/docs/language/scenes/kw-scene.md
index ee9a6106..02f35e92 100644
--- a/docs/language/scenes/kw-scene.md
+++ b/docs/language/scenes/kw-scene.md
@@ -5,12 +5,40 @@ category: scenes
kind: keyword
tokens: scene
sig: scene Name [start] { on enter{} on exit{} layer … }
-tip: A mutually-exclusive game state.
+tip: A mutually-exclusive game state — a title screen, the overworld, a battle.
order: 0
---
-A mutually-exclusive game state. Mark one start.
+A `scene` is one of the mutually-exclusive states a game moves between — a title screen, the overworld, a battle, a shop. Exactly one scene is active at a time, and only the active scene's `layer` handlers run each phase; handlers declared outside any scene are global and run every frame regardless. A scene may carry an `on enter` / `on exit` pair of lifecycle hooks and one or more `layer` blocks that group its handlers. Under the hood a scene lowers to an implicit state machine the compiler writes for you (scenes number themselves by declaration order), so `become` from one scene to another costs two direct calls and a register store — no dispatch table.
```ludic
-scene Title start { on enter { … } layer Main { … } }
+program SceneExample {
+ var elapsed_frames: int = 0
+
+ scene Title start {
+ on enter { elapsed_frames = 0 }
+ layer Main {
+ handler WaitForStart phase Update {
+ elapsed_frames = elapsed_frames + 1
+ if Input.key() != 0 { become Overworld }
+ }
+ handler DrawTitle phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 60, y: 100, text: "PRESS ANY KEY", color: Color.White, scale: 1)
+ Screen.show()
+ }
+ }
+ }
+
+ scene Overworld {
+ on enter { elapsed_frames = 0 }
+ layer World {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.fill_rectangle(x: 80, y: 80, width: 16, height: 16, color: Color.LimeGreen)
+ Screen.show()
+ }
+ }
+ }
+}
```
diff --git a/docs/language/scenes/kw-start.md b/docs/language/scenes/kw-start.md
new file mode 100644
index 00000000..67dd0be3
--- /dev/null
+++ b/docs/language/scenes/kw-start.md
@@ -0,0 +1,40 @@
+---
+id: kw-start
+name: start
+category: scenes
+kind: keyword
+tokens: start
+sig: scene Name start { … }
+tip: Marks the one scene the game begins in.
+order: 50
+---
+
+`start` marks the scene the game begins in. Written right after the scene's name (`scene Title start { … }`), it tells the compiler which scene becomes active at boot: its `on enter` hook fires once, immediately after the `Start` phase, and its layers begin dispatching on the first frame. Mark exactly one scene `start`; if no scene is marked, the first one declared is used. Every other scene is reached later with `become`.
+
+```ludic
+program StartSceneExample {
+ scene MainMenu start {
+ on enter { Screen.status("main menu") }
+ layer Menu {
+ handler ChooseOption phase Update {
+ if Input.key() == 'p' { become Overworld }
+ }
+ handler DrawMenu phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 40, y: 90, text: "PRESS P TO PLAY", color: Color.White, scale: 1)
+ Screen.show()
+ }
+ }
+ }
+
+ scene Overworld {
+ layer World {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.fill_rectangle(x: 100, y: 100, width: 16, height: 16, color: Color.Gold)
+ Screen.show()
+ }
+ }
+ }
+}
+```
diff --git a/docs/language/screen/screen-clear.md b/docs/language/screen/screen-clear.md
index 99ec0ae2..e10596a2 100644
--- a/docs/language/screen/screen-clear.md
+++ b/docs/language/screen/screen-clear.md
@@ -5,12 +5,29 @@ category: screen
kind: namespace-method
tokens: Screen.clear
sig: Screen.clear(color)
-tip: Fill the entire screen with one color.
+tip: Fill the whole framebuffer with one color to start a fresh frame.
order: 0
+ns: Screen
+member: clear
---
-Fill the entire screen with one color.
+Paints every pixel of the framebuffer a single color, wiping whatever the previous frame drew. Call it first inside your Render handler so each frame starts from a known background instead of leftover pixels. The one argument is a color, which can be a named palette value such as Color.MidnightBlue or a raw hex literal like 0x0d1020. After clearing, draw the scene on top and finish with Screen.show().
+
+Parameters:
+- `color` — the background color to fill with, e.g. a `Color.*` name or a `0xRRGGBB` literal
```ludic
-Screen.clear(Color.MidnightBlue)
+program ClearBackground {
+ var elapsed_frames: int = 0
+
+ handler CountFrames phase Update {
+ elapsed_frames = elapsed_frames + 1
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_number(x: 8, y: 8, value: elapsed_frames, color: Color.White, scale: 1)
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/screen/screen-draw_number.md b/docs/language/screen/screen-draw_number.md
index 5ab84bcc..c8f41f91 100644
--- a/docs/language/screen/screen-draw_number.md
+++ b/docs/language/screen/screen-draw_number.md
@@ -5,12 +5,34 @@ category: screen
kind: namespace-method
tokens: Screen.draw_number
sig: Screen.draw_number(x, y, value, color, scale)
-tip: Draw an integer — no allocation, no string conversion.
+tip: Draw an integer directly, with no string allocation or conversion.
order: 5
+ns: Screen
+member: draw_number
---
-Draw an integer — no allocation, no string conversion.
+Draws an integer as digits using the built-in font, without first turning it into a string — so it never allocates and is safe to call every frame for constantly-changing values. Use it for scores, timers, frame counts, health totals, and any HUD number, while reserving Screen.draw_text for fixed labels. It takes the same top-left position, color, and integer scale as draw_text. Negative values are drawn with a leading minus sign.
+
+Parameters:
+- `x` — the left edge of the first digit, in pixels
+- `y` — the top edge of the number, in pixels
+- `value` — the integer to draw
+- `color` — the digit color, e.g. a `Color.*` name or a `0xRRGGBB` literal
+- `scale` — integer size multiplier (`1` = native size)
```ludic
-Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+program ScoreHud {
+ var score: int = 0
+
+ handler EarnPoints phase Update {
+ score = score + 1
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+ Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/screen/screen-draw_rectangle.md b/docs/language/screen/screen-draw_rectangle.md
index eb1f9b0f..89a74924 100644
--- a/docs/language/screen/screen-draw_rectangle.md
+++ b/docs/language/screen/screen-draw_rectangle.md
@@ -5,8 +5,41 @@ category: screen
kind: namespace-method
tokens: Screen.draw_rectangle
sig: Screen.draw_rectangle(x, y, width, height, color)
-tip: Draw a one-pixel rectangle outline.
+tip: Draw a one-pixel-thick rectangle outline (not filled).
order: 2
+ns: Screen
+member: draw_rectangle
---
-Draw a one-pixel rectangle outline.
+Draws just the border of a rectangle — a one-pixel-thick outline — leaving the interior untouched so whatever is behind it shows through. It takes the same top-left position and size as Screen.fill_rectangle, but is meant for framing rather than filling: selection boxes, UI panels, health-bar borders, or highlighting the tile under a cursor. Use it together with fill_rectangle when you want a filled shape with a contrasting edge. Coordinates are in pixels from the top-left of the screen.
+
+Parameters:
+- `x` — the left edge, in pixels from the left of the screen
+- `y` — the top edge, in pixels from the top of the screen
+- `width` — the outline width in pixels
+- `height` — the outline height in pixels
+- `color` — the outline color, e.g. a `Color.*` name or a `0xRRGGBB` literal
+
+```ludic
+program SelectionFrame {
+ property Position { column: int = 0, row: int = 0 }
+ model Enemy { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler SpawnEnemy phase Start {
+ spawn Enemy { Position { column: 6, row: 5 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Enemy}] {
+ let pixel_x = Position.column * TILE_SIZE
+ let pixel_y = Position.row * TILE_SIZE
+ Screen.fill_rectangle(x: pixel_x, y: pixel_y, width: TILE_SIZE, height: TILE_SIZE, color: Color.Crimson)
+ Screen.draw_rectangle(x: pixel_x, y: pixel_y, width: TILE_SIZE, height: TILE_SIZE, color: Color.Gold)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/screen/screen-draw_text.md b/docs/language/screen/screen-draw_text.md
index 39a32223..11e53020 100644
--- a/docs/language/screen/screen-draw_text.md
+++ b/docs/language/screen/screen-draw_text.md
@@ -7,10 +7,32 @@ tokens: Screen.draw_text
sig: Screen.draw_text(x, y, text, color, scale)
tip: Draw a string with the built-in font at an integer scale.
order: 4
+ns: Screen
+member: draw_text
---
-Draw a string with the built-in font at an integer scale.
+Draws a string using Ludic's built-in bitmap font, starting at the top-left pixel (x, y). The scale is an integer multiplier: 1 is the native font size, 2 is double-height and double-width, and so on — there are no fractional sizes. Use it for labels, menus, and messages such as titles or "GAME OVER"; for numbers that change every frame prefer Screen.draw_number, which needs no string conversion. The text is drawn in the given color over whatever is already on screen.
+
+Parameters:
+- `x` — the left edge of the first character, in pixels
+- `y` — the top edge of the text, in pixels
+- `text` — the string to draw
+- `color` — the text color, e.g. a `Color.*` name or a `0xRRGGBB` literal
+- `scale` — integer size multiplier (`1` = native size)
```ludic
-Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+program TitleScreen {
+ var elapsed_frames: int = 0
+
+ handler CountFrames phase Update {
+ elapsed_frames = elapsed_frames + 1
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 40, y: 60, text: "CHRONORIFT", color: Color.Gold, scale: 3)
+ Screen.draw_text(x: 64, y: 120, text: "PRESS ANY KEY", color: Color.White, scale: 1)
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/screen/screen-fill_rectangle.md b/docs/language/screen/screen-fill_rectangle.md
index e7fd2423..1e77711b 100644
--- a/docs/language/screen/screen-fill_rectangle.md
+++ b/docs/language/screen/screen-fill_rectangle.md
@@ -5,12 +5,43 @@ category: screen
kind: namespace-method
tokens: Screen.fill_rectangle
sig: Screen.fill_rectangle(x, y, width, height, color)
-tip: Draw a filled rectangle.
+tip: Draw a solid, filled rectangle at a pixel position.
order: 1
+ns: Screen
+member: fill_rectangle
---
-Draw a filled rectangle.
+Draws a solid block of color from the top-left corner (x, y) spanning width by height pixels. This is the workhorse for drawing tiles, sprites-from-primitives, health bars, and background panels, since a Ludic game paints every pixel itself rather than loading image assets. Coordinates are in pixels measured from the top-left of the screen, so multiply a grid column/row by your tile size to place things on a board. Parts of the rectangle that fall outside the framebuffer are simply clipped.
+
+Parameters:
+- `x` — the left edge, in pixels from the left of the screen
+- `y` — the top edge, in pixels from the top of the screen
+- `width` — the rectangle width in pixels
+- `height` — the rectangle height in pixels
+- `color` — the fill color, e.g. a `Color.*` name or a `0xRRGGBB` literal
```ludic
-Screen.fill_rectangle(x: 8, y: 8, width: 16, height: 16, color: Color.Crimson)
+program FilledTiles {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 4, row: 3 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/screen/screen-height.md b/docs/language/screen/screen-height.md
index f795b9fe..3aad9ec1 100644
--- a/docs/language/screen/screen-height.md
+++ b/docs/language/screen/screen-height.md
@@ -5,8 +5,37 @@ category: screen
kind: namespace-method
tokens: Screen.height
sig: Screen.height() -> int
-tip: Framebuffer height in pixels.
+tip: The framebuffer height in pixels.
order: 8
+ns: Screen
+member: height
---
-Framebuffer height in pixels.
+Returns the height of the framebuffer in pixels — the number of drawable rows. Use it, together with Screen.width, to position elements relative to the bottom or vertical center of the screen instead of assuming a fixed size. A common use is keeping a moving object on screen by clamping its pixel row against this value. It takes no arguments and can be read during any handler.
+
+```ludic
+program GroundLine {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 3, row: 0 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ let ground_y = Screen.height() - TILE_SIZE
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: ground_y,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/screen/screen-put_pixel.md b/docs/language/screen/screen-put_pixel.md
index 4aca1265..12065edc 100644
--- a/docs/language/screen/screen-put_pixel.md
+++ b/docs/language/screen/screen-put_pixel.md
@@ -5,8 +5,42 @@ category: screen
kind: namespace-method
tokens: Screen.put_pixel
sig: Screen.put_pixel(x, y, color)
-tip: Set a single pixel.
+tip: Set a single pixel at a pixel coordinate to one color.
order: 3
+ns: Screen
+member: put_pixel
---
-Set a single pixel.
+Sets exactly one pixel at (x, y) to the given color — the finest-grained drawing operation Ludic offers. Reach for it when you are plotting individual points such as stars, particles, a scatter of sparks, or hand-drawn curves where a full rectangle would be too coarse. For anything larger than a point prefer Screen.fill_rectangle, since one call per pixel is far more expensive than one call per block. Coordinates are pixels from the top-left of the screen, and out-of-range coordinates are ignored.
+
+Parameters:
+- `x` — the pixel column, in pixels from the left of the screen
+- `y` — the pixel row, in pixels from the top of the screen
+- `color` — the pixel color, e.g. a `Color.*` name or a `0xRRGGBB` literal
+
+```ludic
+program Starfield {
+ property Position { column: int = 0, row: int = 0 }
+ model Star { Position }
+
+ handler SeedStars phase Start {
+ Random.seed(value: 1)
+ for spawn_index in 0 .. 40 {
+ spawn Star {
+ Position {
+ column: Random.range(low: 0, high: 255),
+ row: Random.range(low: 0, high: 223)
+ }
+ }
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Star}] {
+ Screen.put_pixel(x: Position.column, y: Position.row, color: Color.White)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/screen/screen-show.md b/docs/language/screen/screen-show.md
index 3f2c7be1..234dfc25 100644
--- a/docs/language/screen/screen-show.md
+++ b/docs/language/screen/screen-show.md
@@ -5,12 +5,36 @@ category: screen
kind: namespace-method
tokens: Screen.show
sig: Screen.show()
-tip: Present the finished frame — copy everything you have drawn to the window.
+tip: Present the finished frame — copy everything drawn this frame to the window.
order: 6
+ns: Screen
+member: show
---
-Present the finished frame — copy everything you have drawn to the window. Call it once, last, in your Render handler.
+Presents the frame: it copies everything you have drawn into the framebuffer this pass onto the visible window all at once. Call it exactly once, as the very last statement of your Render handler, after every Screen.clear, fill_rectangle, draw_text, and other draw call. Because drawing happens off-screen and only show makes it visible, the player never sees a half-drawn frame (no flicker or tearing). Forgetting to call it means your drawing never appears; it takes no arguments.
```ludic
-Screen.show()
+program PresentFrame {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ const TILE_SIZE: int = 16
+
+ handler SpawnPlayer phase Start {
+ spawn Player { Position { column: 5, row: 5 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (Position) in query [Position, {Player}] {
+ Screen.fill_rectangle(
+ x: Position.column * TILE_SIZE,
+ y: Position.row * TILE_SIZE,
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/screen/screen-status.md b/docs/language/screen/screen-status.md
index b4da7f82..7d029964 100644
--- a/docs/language/screen/screen-status.md
+++ b/docs/language/screen/screen-status.md
@@ -5,8 +5,29 @@ category: screen
kind: namespace-method
tokens: Screen.status
sig: Screen.status(text)
-tip: Set the persistent one-line status/HUD string.
+tip: Set the persistent one-line status/HUD string shown by the runtime.
order: 9
+ns: Screen
+member: status
---
-Set the persistent one-line status/HUD string.
+Sets a single persistent line of status text that the runtime displays outside your normal drawing — a lightweight HUD line for debugging or a title-bar style message. Unlike Screen.draw_text, it is not part of the framebuffer you clear and present each frame: you set it once (or whenever it changes) and it persists until you set it again. Use it for coarse status such as the current level, connection state, or a frame-rate readout while iterating. It takes a single string argument.
+
+Parameters:
+- `text` — the status line to display
+
+```ludic
+program StatusLine {
+ var elapsed_frames: int = 0
+
+ handler UpdateStatus phase Update {
+ elapsed_frames = elapsed_frames + 1
+ Screen.status(text: `frame {elapsed_frames}`)
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/screen/screen-width.md b/docs/language/screen/screen-width.md
index 7d3725a5..c3ee099f 100644
--- a/docs/language/screen/screen-width.md
+++ b/docs/language/screen/screen-width.md
@@ -5,8 +5,22 @@ category: screen
kind: namespace-method
tokens: Screen.width
sig: Screen.width() -> int
-tip: Framebuffer width in pixels.
+tip: The framebuffer width in pixels.
order: 7
+ns: Screen
+member: width
---
-Framebuffer width in pixels.
+Returns the width of the framebuffer in pixels — the number of drawable columns. Use it to lay out and center content relative to the actual screen size rather than hard-coding a fixed number, so your HUD or menu stays positioned correctly if the resolution changes. It pairs with Screen.height for full-screen calculations such as clamping a moving object inside the visible area. It takes no arguments and can be read during any handler.
+
+```ludic
+program CenteredLabel {
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ let label_width = 10 * 6
+ let centered_x = (Screen.width() - label_width) / 2
+ Screen.draw_text(x: centered_x, y: 40, text: "CHRONORIFT", color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-const.md b/docs/language/structure/kw-const.md
index c0476c4e..ec4dee41 100644
--- a/docs/language/structure/kw-const.md
+++ b/docs/language/structure/kw-const.md
@@ -5,8 +5,31 @@ category: structure
kind: keyword
tokens: const
sig: const NAME: T = value
-tip: A compile-time constant.
+tip: A compile-time constant, folded into the code with no storage.
order: 5
---
-A compile-time constant. Folds directly into the code — no storage, no cost.
+A const binds a name to a value known at compile time. Unlike `var`, it has no storage and cannot be reassigned — the compiler folds it directly into wherever it is used, so it costs nothing at runtime. Reach for `const` for the fixed dimensions and magic numbers of your game — grid sizes, tile pixels, tuning values — so the code reads in names instead of literals. Give constants descriptive, uppercase names, and use them everywhere the value appears so a single edit changes the whole game.
+
+```ludic
+program Board {
+ const GRID_WIDTH: int = 20
+ const GRID_HEIGHT: int = 15
+ const TILE_SIZE: int = 16
+
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: GRID_WIDTH / 2, row: GRID_HEIGHT / 2 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: position.column * TILE_SIZE, y: position.row * TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE, color: Color.Gold)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-enum.md b/docs/language/structure/kw-enum.md
new file mode 100644
index 00000000..6016977a
--- /dev/null
+++ b/docs/language/structure/kw-enum.md
@@ -0,0 +1,37 @@
+---
+id: kw-enum
+name: enum
+category: structure
+kind: keyword
+tokens: enum
+sig: enum Name { A, B, C }
+tip: A named set of integer constants — names for a magic-number space.
+order: 50
+---
+
+An enum gives names to a set of related integer values so a magic-number space — a menu selection, a game mode, a machine state — reads as names instead of bare literals. Variants number themselves from `0` in declaration order, and you access one as `Name.Variant`, which is a compile-time `int` usable anywhere an int is: in `match` patterns, comparisons, and assignments. Ludic has no distinct enum runtime type yet — an enum value lives in an ordinary `int` or `var` and is saved with it — so `enum` is best understood as a readable naming layer over `int`.
+
+```ludic
+program BattleMenu {
+ enum Action { Attack, Guard, Item, Flee }
+
+ var current_action: int = Action.Attack
+
+ handler ChooseAction phase Input {
+ let pressed = Input.key()
+ if pressed == 'a' { current_action = Action.Attack }
+ if pressed == 'g' { current_action = Action.Guard }
+ if pressed == 'f' { current_action = Action.Flee }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ match current_action {
+ Action.Attack => Screen.draw_text(x: 8, y: 8, text: "ATTACK", color: Color.Crimson, scale: 2)
+ Action.Guard => Screen.draw_text(x: 8, y: 8, text: "GUARD", color: Color.White, scale: 2)
+ _ => Screen.draw_text(x: 8, y: 8, text: "FLEE", color: Color.Gold, scale: 2)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-extern.md b/docs/language/structure/kw-extern.md
index e3250e86..eed3427e 100644
--- a/docs/language/structure/kw-extern.md
+++ b/docs/language/structure/kw-extern.md
@@ -9,4 +9,23 @@ tip: Bind a name to an external C-ABI symbol — the seam for platform and libra
order: 12
---
-Bind a name to an external C-ABI symbol — the seam for platform and library calls.
+An extern fn declares a function whose body lives outside Ludic and binds it to a C-ABI symbol resolved at link time. It is the seam through which Ludic reaches anything with a C interface — a system library, a math routine, or even another `.ludic` file compiled as a `module`. You write the Ludic signature you want to call and give the real symbol name after `=`; the linker connects them (pass `-L`/`-l` to `ludicc` to point at the library). Types must match the foreign ABI, so map each parameter and the return to the right Ludic type (`int`, `fixed`, `ptr`, …).
+
+Parameters:
+- `a` — a typed argument passed straight through to the foreign symbol
+
+```ludic
+program Physics {
+ extern fn c_hypot(a: fixed, b: fixed) -> fixed = "hypot_fx"
+
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Projectile { Velocity }
+
+ handler MeasureSpeed phase Update {
+ for (velocity) in query [Velocity, {Projectile}] {
+ let speed = c_hypot(a: fx(velocity.delta_x), b: fx(velocity.delta_y))
+ Screen.status(str(flr(speed)))
+ }
+ }
+}
+```
diff --git a/docs/language/structure/kw-fn.md b/docs/language/structure/kw-fn.md
index 296a7d40..47717353 100644
--- a/docs/language/structure/kw-fn.md
+++ b/docs/language/structure/kw-fn.md
@@ -5,8 +5,34 @@ category: structure
kind: keyword
tokens: fn
sig: fn name(a: T, b: T) -> R { … }
-tip: A function.
+tip: A function — reusable logic called positionally or with named arguments.
order: 8
---
-A function. Call it positionally or with named arguments: name(a: 1, b: 2).
+A fn declares a function: a reusable block of logic with typed parameters and a return type, written `-> R` (use `-> void` for one that returns nothing). Call it positionally, `heal(2, 10)`, or with named arguments, `heal(amount: 2, maximum: 10)`, which reads more clearly at the call site and is the house style. Functions live at program scope alongside handlers and may be called from any handler; they can `spawn`, run queries, and read program-scope state. Use them to factor out logic shared by several handlers so each handler stays short.
+
+Parameters:
+- `a`, `b` — the typed inputs; pass them positionally or by name at the call site
+
+```ludic
+program Healer {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Health }
+
+ fn heal(amount: int, maximum: int) -> int {
+ let restored = amount * 2
+ if restored > maximum { return maximum }
+ return restored
+ }
+
+ handler Boot phase Start {
+ spawn Hero { Health { current: 10, maximum: 100 } }
+ }
+
+ handler Recover phase Update {
+ for (health) in query [Health, {Player}] {
+ health.current = heal(amount: health.current, maximum: health.maximum)
+ }
+ }
+}
+```
diff --git a/docs/language/structure/kw-handler.md b/docs/language/structure/kw-handler.md
index ca8143d5..51e46687 100644
--- a/docs/language/structure/kw-handler.md
+++ b/docs/language/structure/kw-handler.md
@@ -5,12 +5,34 @@ category: structure
kind: keyword
tokens: handler
sig: handler Name phase P { … }
-tip: A block of code the engine runs every frame during phase P.
+tip: A named block the engine runs each frame during phase P.
order: 3
---
-A block of code the engine runs every frame during phase P. With a query, the body runs once per matching entity.
+A handler is a named block of behavior that the engine runs automatically during a given `phase` — the unit that turns your data into a game. A handler with no query runs once per phase tick; a handler with a `@Queries` annotation (or an inline `for (…) in query […]` loop) runs its body once per matching model instance, with each property bound by name and `self()` giving the current instance. Handlers are registered implicitly just by being declared, and you can pause one at runtime with `disable Handler` and bring it back with `enable Handler`. Keep behavior in handlers and keep data plain in properties — that separation is the whole point.
```ludic
-handler Move phase Update { … }
+program Runner {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Player { Position, Velocity }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 0, row: 6 }; Velocity { delta_x: 1 } }
+ }
+
+ handler AdvancePositions phase Update {
+ for (position, velocity) in query [Position, Velocity, {Player}] {
+ position.column = position.column + velocity.delta_x
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: position.column * 16, y: position.row * 16, width: 16, height: 16, color: Color.LimeGreen)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/structure/kw-import.md b/docs/language/structure/kw-import.md
index 308f1641..712ed69e 100644
--- a/docs/language/structure/kw-import.md
+++ b/docs/language/structure/kw-import.md
@@ -5,8 +5,19 @@ category: structure
kind: keyword
tokens: import
sig: import "file.ludic"
-tip: Splice another Ludic file into this program.
+tip: Splice another Ludic file's declarations into this program.
order: 10
---
-Splice another Ludic file into this program. Paths resolve relative to the importer; re-imports are free.
+An import pulls the declarations of another Ludic file into this program, letting you split a game across many files instead of one giant block. The imported file is a fragment — bare declarations with no `program` wrapper — and its contents are spliced in as if written here. Paths resolve relative to the importing file, imports may nest, and each resolved path is include-guarded, so importing the same file twice (even through different chains) pulls it in exactly once. Diagnostics still point at the real source file, so errors in an imported fragment report that file's name and line.
+
+```ludic
+program ChronoRift {
+ import "chronorift/world.ludic" # properties and models
+ import "chronorift/combat.ludic" # the battle handlers
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 4, row: 4 } }
+ }
+}
+```
diff --git a/docs/language/structure/kw-let.md b/docs/language/structure/kw-let.md
index 1d0ea24c..83edeb56 100644
--- a/docs/language/structure/kw-let.md
+++ b/docs/language/structure/kw-let.md
@@ -5,8 +5,27 @@ category: structure
kind: keyword
tokens: let
sig: let name = value
-tip: An immutable binding, scoped to the block it appears in.
+tip: An immutable binding — the default choice for a value that never changes.
order: 7
---
-An immutable binding, scoped to the block it appears in.
+A let introduces an immutable binding: once set, reassigning it (`name = …`) is a compile error. Reach for `let` by default and only switch to `var` when a value genuinely needs to change — it makes intent obvious and catches accidental writes. Immutability is of the binding, not the object it points at: a `let` that holds a property record or a slice still lets you mutate through it (`node.current = 5`), it just cannot be repointed at a different object. Bindings inside a body are locals scoped to their block.
+
+```ludic
+program Damage {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Enemy { Health }
+
+ handler Boot phase Start {
+ spawn Grunt { Health { current: 40 } }
+ }
+
+ handler ApplyHit phase Update {
+ let incoming_damage = 12
+ for (health) in query [Health, {Enemy}] {
+ let survivor = health.current - incoming_damage
+ health.current = survivor
+ }
+ }
+}
+```
diff --git a/docs/language/structure/kw-model.md b/docs/language/structure/kw-model.md
index 8f079e70..20f60a19 100644
--- a/docs/language/structure/kw-model.md
+++ b/docs/language/structure/kw-model.md
@@ -5,12 +5,30 @@ category: structure
kind: keyword
tokens: model
sig: model Name { PropA, PropB, … }
-tip: A named bundle of properties, so an entity that always travels together is spawned by one name.
+tip: A named kind of thing — a fixed bundle of properties you spawn by one name.
order: 2
---
-A named bundle of properties, so an entity that always travels together is spawned by one name.
+A model names a kind of thing in your game and the fixed set of properties every instance of it carries. Instead of attaching properties one by one, you `spawn` the model by name and every listed property comes with it, seeded from its defaults. A model's name doubles as a query tag: write `{Player}` inside a `query [...]` to match only instances of that model. The model itself has no fields of its own, so you never bind it to a variable — you bind its properties and filter by its tag.
```ludic
-model Player { Health, Shield }
+program Arena {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+
+ model Player { Position, Health }
+ model Enemy { Position, Velocity, Health }
+
+ handler Spawn phase Start {
+ spawn Hero { Position { column: 2, row: 8 } }
+ spawn Grunt { Position { column: 18, row: 8 }; Velocity { delta_x: -1 } }
+ }
+
+ handler AdvanceEnemies phase Update {
+ for (position, velocity) in query [Position, Velocity, {Enemy}] {
+ position.column = position.column + velocity.delta_x
+ }
+ }
+}
```
diff --git a/docs/language/structure/kw-module.md b/docs/language/structure/kw-module.md
deleted file mode 100644
index 4aec9649..00000000
--- a/docs/language/structure/kw-module.md
+++ /dev/null
@@ -1,12 +0,0 @@
----
-id: kw-module
-name: module
-category: structure
-kind: keyword
-tokens: module
-sig: module Name { @export fn … }
-tip: Build a shared library of plain C-ABI symbols instead of an executable.
-order: 11
----
-
-Build a shared library of plain C-ABI symbols instead of an executable.
diff --git a/docs/language/structure/kw-phase.md b/docs/language/structure/kw-phase.md
index 99f1ab8b..b3976d1e 100644
--- a/docs/language/structure/kw-phase.md
+++ b/docs/language/structure/kw-phase.md
@@ -2,11 +2,45 @@
id: kw-phase
name: phase
category: structure
-kind: phase
-tokens: Start Input Update FixedUpdate Render
+kind: keyword
+tokens: phase
sig: phase Start | Input | Update | FixedUpdate | Render
-tip: When a handler runs.
+tip: Names which stage of the frame a handler runs in.
order: 4
---
-When a handler runs. Start once at boot; Input reads the keyboard; Update is the per-frame step; FixedUpdate is the deterministic fixed-step; Render draws the frame.
+Every handler declares a phase — the stage of the frame in which the engine calls it. Start runs once at boot, before the first frame, and is where you seed the world. Then every frame the engine runs, in order, Input (read the keyboard), FixedUpdate (the deterministic fixed-timestep step, for physics and anything that must be reproducible), Update (the ordinary per-frame logic), and Render (draw the frame, ending with `Screen.show()`). Handlers in the same phase run in declaration order, so ordering within a phase is under your control. Put drawing only in `Render`; put keyboard reads in `Input`.
+
+```ludic
+program PhaseTour {
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
+ model Player { Position, Velocity }
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 5, row: 5 } }
+ }
+
+ handler ReadKeys phase Input {
+ let pressed = Input.key()
+ for (velocity) in query [Velocity, {Player}] {
+ if pressed == 'd' { velocity.delta_x = 1 }
+ if pressed == 'a' { velocity.delta_x = -1 }
+ }
+ }
+
+ handler AdvancePositions phase Update {
+ for (position, velocity) in query [Position, Velocity, {Player}] {
+ position.column = position.column + velocity.delta_x
+ }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: position.column * 16, y: position.row * 16, width: 16, height: 16, color: Color.White)
+ }
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-program.md b/docs/language/structure/kw-program.md
index ab575b19..dd12dafb 100644
--- a/docs/language/structure/kw-program.md
+++ b/docs/language/structure/kw-program.md
@@ -5,8 +5,30 @@ category: structure
kind: keyword
tokens: program
sig: program Name { … }
-tip: The top-level unit.
+tip: The top-level unit — one program compiles to one native game.
order: 0
---
-The top-level unit. A program compiles to one native game; everything else lives inside it.
+A program is the outermost unit of Ludic source, and everything else — properties, models, handlers, functions, constants and your named state — lives inside its braces. Each program compiles to exactly one native game (or, with a `module`, one shared library), so a project has a single top-level `program` block. The name you give it is used for the built binary and for diagnostics, and by convention matches the file. When the engine boots, it runs your `Start` handlers once and then drives the per-frame phase loop until the game quits.
+
+```ludic
+program SpaceDrift {
+ property Position { column: int = 0, row: int = 0 }
+ model Player { Position }
+
+ var score: int = 0
+
+ handler Boot phase Start {
+ spawn Hero { Position { column: 10, row: 8 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position) in query [Position, {Player}] {
+ Screen.fill_rectangle(x: position.column * 16, y: position.row * 16, width: 16, height: 16, color: Color.LimeGreen)
+ }
+ Screen.draw_number(x: 6, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-property.md b/docs/language/structure/kw-property.md
index 6c86f35b..657ea5a4 100644
--- a/docs/language/structure/kw-property.md
+++ b/docs/language/structure/kw-property.md
@@ -5,12 +5,29 @@ category: structure
kind: keyword
tokens: property
sig: property Name { field: T = default, … }
-tip: A component: a named record of fields an entity can carry.
+tip: A named record of typed fields — a per-model component, or a plain heap record.
order: 1
---
-A component: a named record of fields an entity can carry. Fields have a type and a default.
+A property declares a named record of typed fields, each with a default value. It is the one record keyword in Ludic, and how you use it decides how it is stored: list it in a `model` (or `attach` it with `spawn`) and it becomes a per-instance component held in the engine's storage and bound in queries; construct it with `new` and it becomes a plain heap record addressed by a pointer. Fields carry a type and a default, so a freshly spawned or `new`-ed property starts fully seeded. Give fields descriptive names — `column`/`row`, not `x`/`y` — because those names are what handlers read and write.
```ludic
-property Pos { x: int = 0, y: int = 0 }
+program Descent {
+ property Position { column: int = 0, row: int = 0 }
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Position, Health }
+
+ handler Spawn phase Start {
+ spawn Hero { Position { column: 4, row: 4 }; Health { current: 80 } }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ for (position, health) in query [Position, Health, {Player}] {
+ let bar_width = health.current / 4
+ Screen.fill_rectangle(x: position.column, y: position.row, width: bar_width, height: 4, color: Color.Crimson)
+ }
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/structure/kw-return.md b/docs/language/structure/kw-return.md
index 15755e6f..38cf7f58 100644
--- a/docs/language/structure/kw-return.md
+++ b/docs/language/structure/kw-return.md
@@ -5,8 +5,31 @@ category: structure
kind: keyword
tokens: return
sig: return value
-tip: Return from a function.
+tip: Hand a value back from a function and stop running it.
order: 9
---
-Return from a function.
+A return statement ends the current function and hands its result back to the caller. Its value must match the function's declared return type; in a `-> void` function you write a bare `return` (or simply let the body end) to exit early. `return` is often paired with an early guard — check a condition and return straight away — which keeps the common path unindented. Inside a handler's query loop, prefer `break` or `continue` to control the loop; `return` leaves the whole function.
+
+```ludic
+program Clamp {
+ property Health { current: int = 100, maximum: int = 100 }
+ model Player { Health }
+
+ fn clamp_current(value: int, maximum: int) -> int {
+ if value < 0 { return 0 }
+ if value > maximum { return maximum }
+ return value
+ }
+
+ handler Boot phase Start {
+ spawn Hero { Health { current: 250, maximum: 100 } }
+ }
+
+ handler Normalize phase Update {
+ for (health) in query [Health, {Player}] {
+ health.current = clamp_current(value: health.current, maximum: health.maximum)
+ }
+ }
+}
+```
diff --git a/docs/language/structure/kw-ui.md b/docs/language/structure/kw-ui.md
new file mode 100644
index 00000000..abc3292c
--- /dev/null
+++ b/docs/language/structure/kw-ui.md
@@ -0,0 +1,43 @@
+---
+id: kw-ui
+name: ui
+category: structure
+kind: keyword
+tokens: ui
+sig: ui { panel { … } }
+tip: Declare a retained widget tree as data; the engine lays it out and draws it.
+order: 50
+---
+
+A ui block declares a retained widget tree as data — panels, labels and buttons — and hands layout, drawing and keyboard focus to the engine, so you describe the interface once instead of repainting it every frame. Widget types are `panel` (a container with optional skin/background/border), `col`/`row` (pure stacks), `label`, `button` (focusable), `image` and `spacer`, and their props are evaluated at build time, so `font: title_font` reads a value the program set first. Each `id: Name` mints a `UI_Name` handle you drive from handlers: call `ui_build()` then `ui_open(UI_MainMenu)` at start, `ui_tick(Input.key())` each update to move focus and activate, and `ui_clicked(UI_Name)` to react. Draw it during `Render` with `ui_render()` between `Screen.clear` and `Screen.show()`.
+
+```ludic
+program TitleScreen {
+ var title_font: int = 0
+
+ ui MainMenu {
+ panel id: Root w: 288 pad: 16 gap: 6 bg: 0x1a1a2c align: center {
+ label text: "CHRONO RIFT" font: title_font size: 26 fg: 0xffe060 align: center
+ button id: NewGame text: "New Game" font: title_font size: 16 w: 236
+ button id: Quit text: "Quit" font: title_font size: 16 w: 236
+ }
+ }
+
+ handler Boot phase Start {
+ title_font = font_load("/System/Library/Fonts/Supplemental/Arial.ttf")
+ ui_build()
+ ui_open(UI_MainMenu)
+ }
+
+ handler Navigate phase Update {
+ ui_tick(Input.key())
+ if ui_clicked(UI_Quit) { quit() }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ ui_render()
+ Screen.show()
+ }
+}
+```
diff --git a/docs/language/structure/kw-var.md b/docs/language/structure/kw-var.md
index 8481a059..a9a79ecc 100644
--- a/docs/language/structure/kw-var.md
+++ b/docs/language/structure/kw-var.md
@@ -5,12 +5,27 @@ category: structure
kind: keyword
tokens: var
sig: var name: T = value
-tip: A mutable binding.
+tip: A mutable binding — at program scope, your game's persistent named state.
order: 6
---
-A mutable binding. At program scope it is your game's persistent, named state — the modern replacement for numeric registers.
+A var declares a mutable binding: unlike `let`, you may reassign it later with `=`, `+=`, `-=`, and friends. Where it appears decides its lifetime — inside a handler or function body it is a local, and at program scope it is persistent, named game state that every handler shares. Program-scope `var`s are the modern replacement for numeric registers: instead of `reg(0)`, you write `var score: int = 0` and read and write `score` by name. Use `var` for anything that genuinely changes — accumulators, the current score, a mode flag, an elapsed-frame counter — and prefer `let` for values that never change.
```ludic
-var score: int = 0
+program Scorekeeper {
+ var score: int = 0
+ var elapsed_frames: int = 0
+
+ handler Tick phase Update {
+ elapsed_frames = elapsed_frames + 1
+ if elapsed_frames % 60 == 0 { score = score + 1 }
+ }
+
+ handler DrawWorld phase Render {
+ Screen.clear(Color.MidnightBlue)
+ Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
+ Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
+ Screen.show()
+ }
+}
```
diff --git a/docs/language/types/type-bool.md b/docs/language/types/type-bool.md
index 742ed671..56c612b9 100644
--- a/docs/language/types/type-bool.md
+++ b/docs/language/types/type-bool.md
@@ -5,8 +5,14 @@ category: types
kind: type
tokens: bool
sig: bool
-tip: A truth value: the result of comparisons and and/or/not.
+tip: A truth value — the result of comparisons and of and / or / not.
order: 2
---
-A truth value: the result of comparisons and and/or/not.
+`bool` is a truth value, either `true` or `false`. It is what a comparison (`== != < <= > >=`) yields and what the boolean combinators `and`, `or`, and `not` produce and consume. A `bool` is what an `if` or `while` condition tests, and some builtins return one directly — `Random.chance(percent:)` answers `true` or `false`. Ludic spells the combinators as words, not symbols: write `and` / `or` / `not`, never `&&`, `||`, or a bare `!`.
+
+```ludic
+let is_alive: bool = current_health > 0
+let should_spawn: bool = Random.chance(percent: 25)
+if is_alive and should_spawn { spawn Enemy { } }
+```
diff --git a/docs/language/types/type-byte.md b/docs/language/types/type-byte.md
index 7b41a0a2..2a2b1cb8 100644
--- a/docs/language/types/type-byte.md
+++ b/docs/language/types/type-byte.md
@@ -5,8 +5,15 @@ category: types
kind: type
tokens: byte
sig: byte
-tip: A single byte, as read from a ptr index.
+tip: A raw buffer indexed one byte at a time — each buffer[i] reads or writes a byte.
order: 7
---
-A single byte, as read from a ptr index.
+`byte` is a raw buffer viewed one byte at a time: indexing `buffer[index]` reads or writes a single 8-bit value. It is the byte-sized view of the memory a `ptr` addresses — allocate it with `bytes(count)` and bind it as `byte` when you want per-byte access, for example to build up text, decode a file, or pack a compact grid. Each element is an `int` in the range of a byte. Like the other raw buffers it is unchecked, so you are responsible for staying in bounds.
+
+```ludic
+let name_buffer: byte = bytes(16)
+name_buffer[0] = 'A'
+name_buffer[1] = 'd'
+name_buffer[2] = 'a'
+```
diff --git a/docs/language/types/type-entity.md b/docs/language/types/type-entity.md
index 29a79ce6..fba24bbb 100644
--- a/docs/language/types/type-entity.md
+++ b/docs/language/types/type-entity.md
@@ -5,8 +5,15 @@ category: types
kind: type
tokens: entity
sig: entity
-tip: An entity id, as returned by self().
+tip: The id/handle of a spawned model instance, as returned by self().
order: 4
---
-An entity id, as returned by self().
+An `entity` is the id — an integer handle — of a spawned model instance. When you `spawn` a `model`, the running thing is a model instance, and its `entity` is the handle you use to refer to it: `self()` yields the `entity` of the instance the current `query` loop is visiting, and statements like `despawn`, `attach`, `detach`, `enable`, and `disable` take an `entity` to say which instance to act on. You rarely do arithmetic on it; think of it as an opaque reference into the ECS rather than a number. It is stored as an `int` under the hood and is part of the saved snapshot.
+
+```ludic
+for (current_position) in query [Position, {Enemy}] {
+ let this_enemy: entity = self()
+ if current_position.row > 14 { despawn this_enemy }
+}
+```
diff --git a/docs/language/types/type-fixed.md b/docs/language/types/type-fixed.md
index f386c28a..b1704900 100644
--- a/docs/language/types/type-fixed.md
+++ b/docs/language/types/type-fixed.md
@@ -5,8 +5,15 @@ category: types
kind: type
tokens: fixed
sig: fixed
-tip: Q16.16 fixed-point — deterministic fractional math.
+tip: Q16.16 fixed-point for deterministic fractional math — no floats.
order: 1
---
-Q16.16 fixed-point — deterministic fractional math. fx(n) lifts an int in; flr(x) takes the floor back out.
+`fixed` is Q16.16 fixed-point: a fractional number stored in 32 bits, giving you decimals without floating-point. Ludic uses it precisely because it is deterministic — the same computation gives the same bits on every machine, which is what a reproducible simulation and lockstep networking need. A literal with a decimal point (`1.5`) is a `fixed`; arithmetic on two `fixed` values does fixed-point multiply/divide, and mixing an `int` with a `fixed` promotes the `int`. Lift an `int` in with `fx(value)` and take the floor back out with `flr(value)`.
+
+```ludic
+const GRAVITY: fixed = 0.5
+var velocity_y: fixed = fx(0)
+var next_velocity: fixed = velocity_y + GRAVITY
+var pixel_row: int = flr(next_velocity)
+```
diff --git a/docs/language/types/type-int.md b/docs/language/types/type-int.md
index 40d6d6b8..fb3b050c 100644
--- a/docs/language/types/type-int.md
+++ b/docs/language/types/type-int.md
@@ -5,8 +5,15 @@ category: types
kind: type
tokens: int
sig: int
-tip: A 32-bit signed integer — the default numeric type, and how colors, keys and tiles are carried.
+tip: The default 32-bit signed integer — also how colors, keys, and tiles are carried.
order: 0
---
-A 32-bit signed integer — the default numeric type, and how colors, keys and tiles are carried.
+`int` is a 32-bit signed integer and the default numeric type in Ludic: a literal like `42` or a hex literal like `0x1E90FF` is an `int`. It does far more than counting — colors are `int` hex values, the key from `Input.key()` is an `int` character code, and a tile from `Map.tile` is an `int`. Arithmetic (`+ - * / %`), comparison, and the bitwise operators all work on it, and it is the natural type for a `score`, a `column`, or an `elapsed_frames` counter. When you need fractions, reach for `fixed` and convert with `fx` / `flr`.
+
+```ludic
+var score: int = 0
+const GRID_WIDTH: int = 20
+var next_column: int = Random.range(low: 0, high: GRID_WIDTH - 1)
+var next_score: int = score + 1
+```
diff --git a/docs/language/types/type-ptr.md b/docs/language/types/type-ptr.md
index f3ce20f0..44c56c05 100644
--- a/docs/language/types/type-ptr.md
+++ b/docs/language/types/type-ptr.md
@@ -5,8 +5,14 @@ category: types
kind: type
tokens: ptr
sig: ptr
-tip: A raw byte buffer (see bytes(n)).
+tip: A raw address into memory — a byte buffer from bytes(n), or an FFI handle.
order: 5
---
-A raw byte buffer (see bytes(n)).
+`ptr` is a raw address into memory — the low-level type for runtime and foreign-function work, not something an everyday game reaches for. Allocate a raw byte buffer with `bytes(count)`, which returns a `ptr` you index as `buffer[index]` to read or write one byte; retype the binding as `words` / `fixeds` / `ptrs` to index in larger element sizes. A `ptr` is also how an `extern fn` passes an opaque C handle across the ABI. Test one for emptiness against the `null` literal.
+
+```ludic
+let scratch: ptr = bytes(256)
+scratch[0] = 65
+if scratch != null { scratch[1] = 66 }
+```
diff --git a/docs/language/types/type-slices.md b/docs/language/types/type-slices.md
index 3056b260..1e306c7a 100644
--- a/docs/language/types/type-slices.md
+++ b/docs/language/types/type-slices.md
@@ -4,8 +4,14 @@ name: []T (slices)
category: types
kind: type
sig: []T
-tip: A growable slice of T.
+tip: A growable slice of T — new []T makes one, push appends, len counts, s[i] indexes.
order: 8
---
-A growable slice of T. new []T makes one; push(s, x) appends; len(s) counts; s[i] indexes.
+`[]T` is a growable slice of `T` — a pointer to a header that holds the data, its length, and its capacity. Create one with `new []T`, append with `push(slice, value)` (the storage doubles when it is full), count elements with `len(slice)`, and read or write an element with `slice[index]`. Because the header never moves, an append is visible to everything holding that slice, giving it reference semantics. Slices are the everyday dynamic collection for records and other values outside the ECS.
+
+```ludic
+let route: []Waypoint = new []Waypoint
+push(route, new Waypoint)
+for step in 0 .. len(route) { Screen.put_pixel(x: route[step].column, y: route[step].row, color: Color.Gold) }
+```
diff --git a/docs/language/types/type-str.md b/docs/language/types/type-str.md
index ce9dc9a3..fe950cd8 100644
--- a/docs/language/types/type-str.md
+++ b/docs/language/types/type-str.md
@@ -5,8 +5,14 @@ category: types
kind: type
tokens: str
sig: str
-tip: An immutable string.
+tip: An immutable string — compared by content, sliceable, and interpolatable.
order: 3
---
-An immutable string. Index for a character code; slice with s[a..b]; interpolate with `text {expr}`.
+`str` is an immutable string of bytes. Strings are true values: `+` concatenates two of them and `==` / `!=` compare them by content, not by pointer, so `"go" + direction == "goleft"` behaves as written. Index a single byte with `text[index]` (an `int` code point), take a fresh substring with `text[start..end]`, and measure the byte length with `len(text)`. The readable way to build one is backtick interpolation — `` `score: {score}` `` — which stringifies each `{…}` hole and concatenates.
+
+```ludic
+let player_name: str = "Ada"
+let greeting: str = `hello {player_name}, score {score}`
+if player_name == "Ada" { Screen.status(greeting) }
+```
diff --git a/docs/language/types/type-void.md b/docs/language/types/type-void.md
new file mode 100644
index 00000000..58ad3620
--- /dev/null
+++ b/docs/language/types/type-void.md
@@ -0,0 +1,19 @@
+---
+id: type-void
+name: void
+category: types
+kind: type
+tokens: void
+sig: void
+tip: The absence of a value — the return type of a function that returns nothing.
+order: 50
+---
+
+`void` is the absence of a value. It appears as the return type of a function that runs for its effect and hands nothing back — `fn reset_score() -> void { … }`. Such a function is called as a statement, not used in an expression, and a bare `return` (with no value) leaves it early. Use `void` whenever a helper mutates program state, draws, or spawns rather than computing a result to return.
+
+```ludic
+fn reset_score() -> void {
+ score = 0
+ return
+}
+```
diff --git a/docs/language/types/type-words.md b/docs/language/types/type-words.md
index a0d1fa9a..ea950594 100644
--- a/docs/language/types/type-words.md
+++ b/docs/language/types/type-words.md
@@ -5,8 +5,14 @@ category: types
kind: type
tokens: words
sig: words
-tip: A raw buffer of 32-bit words (see words(n)).
+tip: A raw buffer indexed as 32-bit words — each buffer[i] reads or writes an int.
order: 6
---
-A raw buffer of 32-bit words (see words(n)).
+`words` is a raw buffer viewed as a sequence of 32-bit words: indexing `buffer[index]` reads or writes one `int`. It is the same underlying memory a `ptr` addresses, retyped so the element size is a word instead of a byte — allocate the storage with `words(count)` (count 32-bit words) and bind it as `words` to index it that way. Reach for it when you need a flat integer array outside the ECS — a lookup table, a scratch grid — and want plain integer indexing without the growable-slice header. Like all raw buffers it is unbounded and unchecked, so keep your own length.
+
+```ludic
+let height_map: words = words(64)
+height_map[0] = 10
+height_map[1] = height_map[0] + 5
+```
diff --git a/docs/site/snippets/hero.ludic b/docs/site/snippets/hero.ludic
index fb147eb5..cf3dd3a5 100644
--- a/docs/site/snippets/hero.ludic
+++ b/docs/site/snippets/hero.ludic
@@ -1,24 +1,27 @@
# the smallest program that exercises the whole ECS pipeline
program Hello {
- property Pos { x: int = 0, y: int = 0 }
- property Vel { dx: int = 0, dy: int = 0 }
+ property Position { column: int = 0, row: int = 0 }
+ property Velocity { delta_x: int = 0, delta_y: int = 0 }
- handler Boot phase Start {
- spawn Mob { Pos { x: 3, y: 4 } Vel { dx: 1, dy: 0 } }
- spawn Mob { Pos { x: 10, y: 2 } Vel { dx: 0, dy: 1 } }
+ handler SpawnEnemies phase Start {
+ spawn Enemy { Position { column: 3, row: 4 }, Velocity { delta_x: 1, delta_y: 0 } }
+ spawn Enemy { Position { column: 10, row: 2 }, Velocity { delta_x: 0, delta_y: 1 } }
}
- # a handler declares the entities it touches; the body
+ # a handler declares the models it touches; the body
# runs once per match, each property bound by name.
- @Queries(these: [Pos, Vel])
- handler Move phase FixedUpdate {
- Pos.x = Pos.x + Vel.dx
- Pos.y = Pos.y + Vel.dy
+ @Queries(these: [Position, Velocity])
+ handler AdvancePositions phase FixedUpdate {
+ Position.column = Position.column + Velocity.delta_x
+ Position.row = Position.row + Velocity.delta_y
}
- handler Report phase Update {
- for (p) in query [Pos] { print(p.x) print(p.y) }
+ handler ReportPositions phase Update {
+ for (moving) in query [Position] {
+ print(moving.column)
+ print(moving.row)
+ }
quit()
}
}
diff --git a/docs/site/snippets/lifecycle.ludic b/docs/site/snippets/lifecycle.ludic
index 6c4371d5..aa64f3ff 100644
--- a/docs/site/snippets/lifecycle.ludic
+++ b/docs/site/snippets/lifecycle.ludic
@@ -1,20 +1,20 @@
program Toggles {
- property Health { hp: int = 0, max: int = 100 }
+ property Health { current: int = 0, maximum: int = 100 }
property Shield { amount: int = 0 }
model Player { Health, Shield }
# hooks fire at the toggle point, data bound by name
- @OnDisable(Shield) handler Down { print(Shield.amount + 1) }
- @OnEnable(Shield) handler Up { print(Shield.amount + 2) }
+ @OnDisable(Shield) handler ShieldDown { print(Shield.amount + 1) }
+ @OnEnable(Shield) handler ShieldUp { print(Shield.amount + 2) }
- handler Seed phase Start {
- spawn Player { Health { max: 50 }, Shield { amount: 5 } }
+ handler SpawnPlayer phase Start {
+ spawn Player { Health { maximum: 50 }, Shield { amount: 5 } }
}
- handler Run phase Render {
- for (e) in query [Player] { disable Shield on self() } # @OnDisable
- for (e) in query [Player] { enable Shield on self() } # @OnEnable, data intact
- disable Player # whole model off
+ handler ToggleShield phase Render {
+ for (player) in query [Player] { disable Shield on self() } # @OnDisable
+ for (player) in query [Player] { enable Shield on self() } # @OnEnable, data intact
+ disable Player # whole model off
quit()
}
}
diff --git a/docs/site/snippets/scenes.ludic b/docs/site/snippets/scenes.ludic
index 4ebb7659..1a6225f5 100644
--- a/docs/site/snippets/scenes.ludic
+++ b/docs/site/snippets/scenes.ludic
@@ -1,7 +1,10 @@
program SceneDemo {
var counter: int = 0
- handler Boot phase Start { counter = 0 print(1000) }
+ handler Boot phase Start {
+ counter = 0
+ print(1000)
+ }
scene Title start {
on enter { print(1) }
@@ -16,8 +19,11 @@ program SceneDemo {
}
scene Play {
- on enter { print(3) counter = 0 }
- on exit { print(4) }
+ on enter {
+ print(3)
+ counter = 0
+ }
+ on exit { print(4) }
layer World {
handler Step phase Update {
counter = counter + 1
@@ -25,6 +31,8 @@ program SceneDemo {
if counter >= 2 { quit() }
}
}
- layer Hud { handler Draw phase Render { print(900) } }
+ layer Hud {
+ handler DrawHud phase Render { print(900) }
+ }
}
}
diff --git a/docs/site/snippets/snake.ludic b/docs/site/snippets/snake.ludic
index 0223ae0e..7b45ae73 100644
--- a/docs/site/snippets/snake.ludic
+++ b/docs/site/snippets/snake.ludic
@@ -1,27 +1,27 @@
program Snake {
- property Pos { x: int = 0, y: int = 0 }
- property Seg { order: int = 0 }
+ property Position { column: int = 0, row: int = 0 }
+ property Segment { index: int = 0 }
- const GRID_W: int = 20 const GRID_H: int = 15 const TILE: int = 16
+ const GRID_WIDTH: int = 20 const GRID_HEIGHT: int = 15 const TILE_SIZE: int = 16
- var food_x: int = 14 var food_y: int = 7 var score: int = 0
+ var food_column: int = 14 var food_row: int = 7 var score: int = 0
- handler Draw phase Render {
+ handler DrawWorld phase Render {
Screen.clear(Color.MidnightBlue)
- # checkerboard field — a fresh, immutable gx/gy each pass
- for gy in 0 .. GRID_H {
- for gx in 0 .. GRID_W {
+ # checkerboard field — a fresh, immutable column/row each pass
+ for row in 0 .. GRID_HEIGHT {
+ for column in 0 .. GRID_WIDTH {
var tile_color = Color.Charcoal
- if (gx + gy) % 2 == 0 { tile_color = Color.Gunmetal }
- Screen.fill_rectangle(x: gx * TILE, y: gy * TILE, width: TILE, height: TILE, color: tile_color)
+ if (column + row) % 2 == 0 { tile_color = Color.Gunmetal }
+ Screen.fill_rectangle(x: column * TILE_SIZE, y: row * TILE_SIZE, width: TILE_SIZE, height: TILE_SIZE, color: tile_color)
}
}
# the food, then every snake segment straight from the ECS
- Screen.fill_rectangle(x: food_x * TILE + 3, y: food_y * TILE + 3, width: TILE - 6, height: TILE - 6, color: Color.Crimson)
- for (p, s) in query [Pos, Seg] {
- var seg_color = Color.LimeGreen
- if s.order == 0 { seg_color = Color.MintGreen } # brighter head
- Screen.fill_rectangle(x: p.x * TILE + 1, y: p.y * TILE + 1, width: TILE - 2, height: TILE - 2, color: seg_color)
+ Screen.fill_rectangle(x: food_column * TILE_SIZE + 3, y: food_row * TILE_SIZE + 3, width: TILE_SIZE - 6, height: TILE_SIZE - 6, color: Color.Crimson)
+ for (position, segment) in query [Position, Segment] {
+ var segment_color = Color.LimeGreen
+ if segment.index == 0 { segment_color = Color.MintGreen } # brighter head
+ Screen.fill_rectangle(x: position.column * TILE_SIZE + 1, y: position.row * TILE_SIZE + 1, width: TILE_SIZE - 2, height: TILE_SIZE - 2, color: segment_color)
}
Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
Screen.draw_number(x: 52, y: 4, value: score, color: Color.Gold, scale: 1)
diff --git a/tools/docgen/README.md b/tools/docgen/README.md
index 3ce8ce8c..28d2cb76 100644
--- a/tools/docgen/README.md
+++ b/tools/docgen/README.md
@@ -7,61 +7,81 @@ source of truth, so the site can never drift from the language.
```
docs/
- language/`, ``) and `backtick code` both work.
+Rich description — inline ``/`backticks`, "model instance" language.
-```ludic
-handler Move phase Update { … }
-```
+Parameters: # for anything that takes arguments
+- `x` — the left edge, in pixels
+- `color` — the fill color, e.g. a `Color.*` name
+
+```ludic
+program Example { … descriptive-named, compilable … }
+```
```
-Add such a file and it appears in the API Reference, is recognized + tipped +
-linked in **every** code snippet across the site, and lands in `symbols.json` —
-with no other file to edit.
+## What it produces
-## Build
+One **page per symbol** (`%(hero_code)s
+ %(feat_title)s
+ %(sc_title)s
+ %(start_title)s
+ %(term)s
+ %(ed_title)s
+ '+esc(c.sig)+'' : "")
+ + '\1", s)
def split_body(body):
- """Return (description_html, [examples]) — fences pulled out as examples."""
examples = FENCE.findall(body)
- desc = FENCE.sub("", body).strip()
- # author convenience: `code` -> code (leaves existing tags alone)
- desc = re.sub(r"`([^`]+)`", r"\1", desc)
- paras = [p.strip() for p in re.split(r"\n\s*\n", desc) if p.strip()]
- desc_html = "
".join(paras) - return desc_html, examples + body = FENCE.sub("", body) + # pull out a "Parameters:" block + params, keep = [], [] + lines = body.split("\n") + i, n = 0, len(lines) + while i < n: + if lines[i].strip().lower().startswith("parameters:"): + i += 1 + while i < n and lines[i].strip(): + m = PARAM.match(lines[i].strip()) + if m: + params.append({"name": m.group(1), "desc": codeify(m.group(2).strip())}) + i += 1 + else: + keep.append(lines[i]); i += 1 + desc = "\n".join(keep).strip() + paras = [codeify(p.strip()) for p in re.split(r"\n\s*\n", desc) if p.strip()] + return "
".join(paras), examples, params
-# ---------------------------------------------------------------------------
-# load the symbol model
-# ---------------------------------------------------------------------------
def load_sections():
sections = []
for cat in sorted(os.listdir(LANG)):
@@ -80,416 +89,304 @@ def load_sections():
if not os.path.isdir(cdir):
continue
smeta, sblurb = {}, ""
- secpath = os.path.join(cdir, "_section.md")
- if os.path.exists(secpath):
- smeta, sbody = parse_doc(secpath)
- sblurb = re.sub(r"`([^`]+)`", r"\1", sbody.strip())
+ sp = os.path.join(cdir, "_section.md")
+ if os.path.exists(sp):
+ smeta, sbody = parse_doc(sp)
+ sblurb = codeify(sbody.strip())
entries = []
- for fn in os.listdir(cdir):
+ for fn in sorted(os.listdir(cdir)):
if not fn.endswith(".md") or fn == "_section.md":
continue
meta, body = parse_doc(os.path.join(cdir, fn))
- desc, examples = split_body(body)
+ desc, examples, params = split_body(body)
meta["desc_html"] = desc
meta["examples"] = examples
- meta["tokens_list"] = meta.get("tokens", "").split() if meta.get("tokens") else []
+ meta["params"] = params
+ meta["tokens_list"] = meta.get("tokens", "").split()
+ meta["related_list"] = meta.get("related", "").split()
entries.append(meta)
entries.sort(key=lambda e: (int(e.get("order", 999)), e.get("name", "")))
- sections.append({
- "id": smeta.get("id", cat),
- "title": smeta.get("title", cat.title()),
- "order": int(smeta.get("order", 999)),
- "blurb": sblurb,
- "entries": entries,
- })
+ sections.append({"id": smeta.get("id", cat), "title": smeta.get("title", cat.title()),
+ "order": int(smeta.get("order", 999)), "blurb": sblurb,
+ "cat": cat, "entries": entries})
sections.sort(key=lambda s: (s["order"], s["title"]))
return sections
# ---------------------------------------------------------------------------
-# build the highlighter symbol tables from the model
+# symbol model for the highlighter + search + cards
# ---------------------------------------------------------------------------
-def build_symbols(sections):
+def ns_page(nsname): return "ns-" + nsname.lower()
+
+def build_symbols(sections, palette):
sym = {"keywords": {}, "types": {}, "phases": {}, "builtins": {},
- "nsmethods": {}, "annotations": {}, "tips": {},
- "namespaces": [], "colors_anchor": "colors", "annotations_anchor": "annotations"}
- namespaces = set()
+ "nsmethods": {}, "annotations": {}, "namespaces": {}, "tips": {},
+ "cards": {}, "colors_page": "ns-color"}
+ items = {} # id -> full record for search
for s in sections:
for e in s["entries"]:
- kind = e.get("kind", "")
- anchor = e["id"]
- tip = e.get("tip", "")
+ eid = e["id"]; kind = e.get("kind", ""); tip = e.get("tip", "")
+ page = eid + ".html"
+ items[eid] = {"id": eid, "name": e.get("name", ""), "sig": e.get("sig", ""),
+ "kind": kind, "category": s["id"], "section": s["title"],
+ "tip": tip, "page": page}
+ sym["cards"][eid] = {"name": e.get("name",""), "sig": e.get("sig",""),
+ "tip": tip, "kind": kind, "page": page, "section": s["title"]}
for tok in e["tokens_list"]:
- if kind == "keyword":
- sym["keywords"][tok] = anchor
- elif kind == "type":
- sym["types"][tok] = anchor
- elif kind == "phase":
- sym["phases"][tok] = anchor
+ if kind == "keyword": sym["keywords"][tok] = eid
+ elif kind == "type": sym["types"][tok] = eid
+ elif kind == "phase": sym["phases"][tok] = eid
elif kind == "builtin":
- sym["builtins"][tok] = anchor
+ sym["builtins"][tok] = {"id": eid, "params": [p["name"] for p in e["params"]]}
elif kind == "namespace-method":
- sym["nsmethods"][tok] = anchor
- if "." in tok:
- namespaces.add(tok.split(".", 1)[0])
+ sym["nsmethods"][tok] = {"id": eid, "params": [p["name"] for p in e["params"]]}
elif kind == "annotation":
- sym["annotations"][tok] = anchor
- if tip:
- sym["tips"][tok] = tip
- if s["id"] == "colors":
- sym["colors_anchor"] = "colors"
- if s["id"] == "annotations":
- sym["annotations_anchor"] = "annotations"
- namespaces.add("Color") # Color.* is recognized and linked to the palette
- sym["namespaces"] = sorted(namespaces)
- return sym
+ sym["annotations"][tok] = eid
+ if tip: sym["tips"][tok] = tip
+ # namespaces: Screen/Input/Random/Map from ns methods, plus Color
+ nsnames = set()
+ for tok in sym["nsmethods"]:
+ if "." in tok: nsnames.add(tok.split(".", 1)[0])
+ nsnames.add("Color")
+ for nn in sorted(nsnames):
+ sym["namespaces"][nn] = ns_page(nn)
+ sym["cards"][ns_page(nn)] = {"name": nn, "sig": nn + ".*", "kind": "namespace",
+ "tip": NS_TIP.get(nn, ""), "page": ns_page(nn) + ".html",
+ "section": "Namespaces"}
+ return sym, items
+
+NS_TIP = {"Screen": "The 2D drawing surface.", "Color": "The named color palette.",
+ "Input": "Reading the keyboard.", "Random": "The seeded, deterministic RNG.",
+ "Map": "The character-grid tilemap."}
# ---------------------------------------------------------------------------
-# render the API Reference
+# shared chrome
# ---------------------------------------------------------------------------
-def render_entry(e):
- ex = ""
- for code in e.get("examples", []):
- ex += '
' + esc(code) + "" - desc = e.get("desc_html", "") - return ( - '' - ).format(id=e["id"], name=esc(e.get("name", "")), sig=esc(e.get("sig", "")), - desc=desc, ex=ex) +def nav_html(cfg, active=None): + out = [] + for n in cfg["nav_links"]: + cls = 'class="nav-cta" ' if n.get("href") == "api.html" else "" + out.append('%s' % (cls, n["href"], esc(n["label"]))) + return "".join(out) -def render_palette(palette): - out = ['
{blurb}
{body}Every keyword, type, builtin, namespace and color in Ludic. In any code sample across this site, hover a token and click to jump straight to its entry here.
-%s'
+ '%s%s' % esc(code) for code in e["examples"]) + return '
@@SIG@@
+ @@DESC@@