docs(api): per-symbol pages, fuzzy search, deep token linking, hover cards
All checks were successful
docs / build-and-deploy (push) Successful in 2s

Rebuild the API Reference around one page per symbol and richer, verified content.

Pages & navigation
- One HTML page per symbol (kw-*, type-*, phase-*, screen-*, fn-*, annot-*, op-*)
  instead of a single scrolling page; namespace overview pages (ns-screen …
  ns-color) and a searchable index (api.html) with client-side fuzzy search.
- Sticky-header scroll offset (scroll-margin) so a jumped-to entry/param/color is
  never hidden, plus a flash highlight on the scrolled-to target.

Deep linking in every snippet & example
- Namespace members split: `Screen`→namespace page, `fill_rectangle`→method page;
  `Color`→palette page, `Charcoal`→its swatch — separately.
- Named arguments (`width:`) link to that parameter's anchor on the method page.
- Hover any token for a summary card built from the real API data (symbols.json).

Content & coverage
- Full authoritative surface documented from the compiler: every keyword, type,
  the 6 phases (Start/Input/FixedUpdate/Update/LateUpdate/Render, each its own
  page), all 22 annotations, namespace methods with parameter docs, builtins,
  the world_* reflection ABI, networking, operators — 155 symbols.
- Longer, clearer explanations; "model"/"model instance" terminology, not "entity";
  descriptive identifiers in every example (Position{column,row}, Velocity{delta_x,
  delta_y}, Health{current,maximum}, Player/Enemy) — no Pos/Seg/x/dx.
- Accuracy fixes from compiler ground-truth: world_count() takes no arg,
  world_query_next(property, cursor) arg order, event fields bind by name; dropped
  `when` and `module` (not in the self-hosted parser).

Tooling
- inventory.json + check.py: coverage guard (every symbol has a page), duplicate-
  token guard, and broken-link guard — fail CI so docs can't drift.
- validate.py: compiles every ```ludic example against bin/ludicc (158 compile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-29 17:53:22 +03:00
parent 25f987e30d
commit 3c7ec9b016
172 changed files with 5240 additions and 895 deletions

View file

@ -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.
<code>@Computed</code> marks a property field as <b>derived</b>: 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 } }
}
}
```

View file

@ -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 <code>module</code>.
<code>@export</code> makes a <code>fn</code> 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 <code>extern fn</code>, 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
}
}
```

View file

@ -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
---
<code>@Handles</code> 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 <code>@On(Event)</code>, which registers the listener; <code>@Handles</code> 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)
}
}
```

View file

@ -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.
<code>@On(Event)</code> turns a handler into a listener for a named <code>event</code>: whenever any code runs <code>emit Event(...)</code>, 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 <code>cancellable event</code>, a listener may <code>cancel</code> to veto it, and the emitter sees that outcome. Related lifecycle hooks such as <code>@OnSpawn</code>, <code>@OnEnable</code> and <code>@OnDespawn</code> 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)
}
}
```

View file

@ -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
---
<code>@OnAttach(Property)</code> fires when the named property is <b>structurally added</b> to a live model instance with <code>attach P on e</code> — 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 <code>@OnDetach</code>; it differs from <code>@OnEnable</code>, 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 <code>@Public</code> to also emit a <code>prop_&lt;Property&gt;_attach</code> 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()
}
}
```

View file

@ -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
---
<code>@OnDespawn(Model)</code> is the teardown counterpart to <code>@OnSpawn</code>: it fires once for each instance of the named model as it is removed, whether by an in-world <code>despawn</code> or at program shutdown, and its body still reads the instance's outgoing field values before they are gone. With the optional <code>reason: r</code> binding it becomes reason-carrying teardown — <code>r</code> is bound to an <code>EndReason</code> the compiler passes at each teardown site (an in-world despawn passes <code>EndReason.Despawned</code>, program exit passes <code>EndReason.Quit</code>) so one hook can branch on <b>why</b> the instance is ending, the way Unreal's <code>EndPlay(reason)</code> 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 <code>@Public</code> to also emit a <code>model_&lt;Model&gt;_despawn</code> 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()
}
}
```

View file

@ -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
---
<code>@OnDetach(Property)</code> is the teardown counterpart to <code>@OnAttach</code>: it fires when the named property is structurally removed from a live instance with <code>detach P on e</code>. The hook runs <b>before</b> 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 <code>@OnDisable</code>, which pauses a property while keeping its data; detach actually destroys the property's presence on the instance. Add <code>@Public</code> to also emit a <code>prop_&lt;Property&gt;_detach</code> 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()
}
}
```

View file

@ -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
---
<code>@OnDisable(Property)</code> fires when a present property is paused with <code>disable P on e</code>. 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 <code>@OnDetach</code>). 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 <code>@OnEnable</code> for capabilities that toggle on and off repeatedly. Add <code>@Public</code> to also emit a <code>prop_&lt;Property&gt;_disable</code> 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()
}
}
```

View file

@ -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
---
<code>@OnEnable(Property)</code> fires when a property that is present but disabled is turned back on with <code>enable P on e</code>. Unlike <code>@OnAttach</code>, 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 <code>@Public</code> to also emit a <code>prop_&lt;Property&gt;_enable</code> 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()
}
}
```

View file

@ -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
---
<code>@OnQuit</code> 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 <code>@OnDespawn</code> hook has fired, so the world has already been torn down by the time <code>@OnQuit</code> sees it. It is the boot-time counterpart of <code>@OnStart</code>; add <code>@Public</code> to also emit a <code>program_quit</code> 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
}
```

View file

@ -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
---
<code>@OnSpawn(Model)</code> turns a handler into a birth hook: it fires once for each newly <code>spawn</code>ed 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 <code>@OnDespawn</code>; add <code>@Public</code> to also emit a <code>model_&lt;Model&gt;_spawn</code> 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 } }
}
}
```

View file

@ -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
---
<code>@OnStart</code> 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 <code>@OnStart handler Name { … }</code> rather than <code>handler Name phase Start</code> and get the same once-at-boot placement through the annotation channel. Its shutdown counterpart is <code>@OnQuit</code>; add <code>@Public</code> to also emit a <code>program_start</code> 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 }
}
```

View file

@ -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
---
<code>@Owned</code> declares that every instance of a model carries a network owner — it adds an owner slot to the model (the runtime's <code>@L_owner</code> array), which the builtins <code>owner</code>, <code>set_owner</code>, and <code>is_owner</code> read and assign. A fresh instance starts unowned (<code>-1</code>) until the authority assigns it. Ownership is what gates who may write <code>@Sync(to: owner)</code> fields and who runs <code>@Predicted</code> 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
}
}
}
```

View file

@ -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
---
<code>@Predicted</code> marks a control handler that runs in two places: speculatively on the client that <b>owns</b> 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 <code>GhostMode.Predicted</code> so the concept transfers. Prediction is explicit — Ludic never silently predicts — and it only applies to instances of an <code>@Owned</code> model, since the dispatch reads <code>is_owner</code> to decide whether the local client should run it. Use it for the owning player's movement and actions; leave authority-only rules on <code>@Server</code>.
```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 } }
}
}
```

View file

@ -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
---
<code>@Public</code> 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: <code>@Public @OnSpawn(Model)</code> emits <code>model_&lt;Model&gt;_spawn</code>, <code>@OnDespawn</code> emits <code>model_&lt;Model&gt;_despawn</code>, the property hooks emit <code>prop_&lt;Property&gt;_attach</code>/<code>_detach</code>/<code>_enable</code>/<code>_disable</code>, and <code>@OnStart</code>/<code>@OnQuit</code> emit <code>program_start</code>/<code>program_quit</code>. This is the same lowering the event system uses, so anyone can <code>@On</code> 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 } }
}
}
```

View file

@ -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.
<code>@Queries</code> 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 <code>Position.column</code> directly instead of opening a <code>for (…) in query […]</code> yourself — and <code>self()</code> gives the current instance. It takes the same shape as a manual query: <code>these: [ … ]</code> lists the required properties (each may carry a filter like <code>Health{current &lt;= 0}</code>), and an optional <code>on: Model</code> 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 <code>query</code> 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 } }
}
}
```

View file

@ -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
---
<code>@Reads(Property)</code> 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 <code>@Queries</code> does, nor does it grant access; it annotates intent alongside the handler's actual query. Pair it with <code>@Writes</code> 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 } }
}
}
```

View file

@ -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
---
<code>@Server</code> marks a handler as server-authoritative: it runs only on the peer acting as the authority, and clients receive its effects through ordinary <code>@Sync</code> replication rather than by running the handler themselves. This is the declarative alternative to sprinkling <code>is_server()</code> 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 <code>@Server</code> 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 } }
}
}
```

View file

@ -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: <code>@Sync</code> says <b>what</b> replicates and <code>@Owned</code> says <b>who</b> 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 <code>@Sync</code>, either on the field or via <code>@Sync property P</code> which marks every field of <code>P</code>) <b>and</b> participating (the model marks the component <code>@Sync</code>). So the same property can replicate in one model and not another, and there is no <code>@NoSync</code> because the surface is purely additive. From these marks the compiler generates the per-model <code>serialize</code>/<code>apply</code> codecs; <code>@Sync</code> on a non-POD-scalar field (like a <code>ptr</code>) is a compile error, since a machine-local pointer cannot cross the wire. <code>@Owned</code> adds the owner slot the ownership builtins and <code>@Predicted</code> read. See the dedicated <code>@Owned</code> 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)
}
}
}
```

View file

@ -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
---
<code>@ToClients</code> marks an <code>event</code> 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 <code>emit</code>s 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 <code>@On</code> dispatch, so an <code>@On(Name)</code> 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 <code>@ToServer</code>, 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
}
}
```

View file

@ -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
---
<code>@ToServer</code> marks an <code>event</code> 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 <code>emit</code> 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 <b>not</b> run locally. On the far side, draining the transport (the runtime's pump) re-emits it into the normal <code>@On</code> dispatch, so a <code>@Server @On(Name)</code> 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 <code>@ToClients</code>, 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
}
}
```

View file

@ -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
---
<code>@Writes(Property)</code> declares that a handler writes the named property. Like <code>@Reads</code> 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 } }
}
}
```

View file

@ -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 <code>abs(-7)</code> and <code>abs(7)</code> both give <code>7</code>. 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) }
}
}
}
}
```

View file

@ -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: <code>arg_count()</code> is the number of arguments (including the program name at index 0) and <code>arg(i)</code> returns the <code>i</code>th as a string.
Returns the command-line argument at index <code>i</code> as a string. Index <code>0</code> is conventionally the program's own name or path, so the first user-supplied argument is at index <code>1</code>. Use it together with <code>arg_count</code> to write configurable command-line tools in Ludic — reading an input filename, a mode flag, or a numeric option. Guard the index against <code>arg_count()</code> 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")
}
}
}
```

View file

@ -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 <code>0</code>. So a program run with no user arguments reports <code>1</code>, 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 <code>arg</code>. It takes no arguments and is the natural companion to <code>arg</code> when building command-line tools.
```ludic
program PrintAllArgs {
handler ListArgs phase Start {
print(arg_count())
for index in 0 .. arg_count() {
print(arg(index))
}
}
}
```

View file

@ -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 <code>n</code> 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 <code>bytes(world_size())</code> so the buffer is exactly large enough for a full world snapshot. For a buffer of 32-bit words rather than individual bytes, use <code>words</code> 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)
}
}
```

View file

@ -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 <code>lo</code> if <code>v</code> is below the range, <code>hi</code> if it is above, and <code>v</code> unchanged when it already lies within <code>[lo, hi]</code>. 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 <code>if</code> checks by hand. Make sure <code>lo</code> is not greater than <code>hi</code>.
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()
}
}
```

View file

@ -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 <code>quit()</code>, which ends the game loop cleanly after the current frame, <code>exit</code> 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 <code>quit</code>, which ends the game loop cleanly after the frame completes; use <code>exit</code> for command-line tools and tests that need to signal success or failure to the shell, where <code>0</code> 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)
}
}
```

View file

@ -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
---
<code>file_stdout()</code> and <code>file_stderr()</code> return the standard stream handles; <code>file_write(handle, buf, len)</code> writes <code>len</code> raw bytes to one. Most code uses <code>print</code> instead — these are the primitive underneath.

View file

@ -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 <code>file_write</code> 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")
}
}
```

View file

@ -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 <code>file_write</code> to emit raw bytes. Unlike <code>print</code>, 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 <code>file_stderr</code> 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))
}
}
```

View file

@ -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 <code>len</code> bytes from <code>buf</code> to the given stream <code>handle</code>. Get the handle from <code>file_stdout</code> or <code>file_stderr</code>; the buffer is typically a string (whose length you pass with <code>len(buf)</code>) or a raw buffer from <code>bytes</code>. 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 <code>print</code> 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))
}
}
```

View file

@ -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 <code>fixed</code> (Q16.16) value back to an <code>int</code> by discarding the fractional part, rounding toward negative infinity. It is the counterpart to <code>fx</code>: you accumulate motion in fixed-point for sub-pixel smoothness, then <code>flr</code> the result to get the whole-pixel column or row to draw at. Because it floors rather than rounds, <code>flr(fx(3) / fx(2))</code> is <code>1</code>, not <code>2</code>. 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()
}
}
```

View file

@ -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.

View file

@ -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 <code>fixed</code> value (Ludic's Q16.16 fixed-point type), so it can take part in fractional arithmetic. Ludic has no floating point; <code>fixed</code> is how you carry sub-pixel precision for smooth movement and physics-like accumulation. Use <code>fx</code> when you need to combine an <code>int</code> with fixed-point values or divide to get a fraction — for example <code>fx(1) / fx(4)</code> is <code>0.25</code>. Convert back to a whole number for drawing with <code>flr</code>.
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))
}
}
```

View file

@ -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 <code>name</code> (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)
}
}
}
```

View file

@ -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 <code>for index in 0 .. len(items)</code>, 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 <code>push</code> 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))
}
}
```

View file

@ -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 <code>save()</code>.
Restores the entire world from the snapshot most recently written by <code>save()</code> — every model instance, property, and program <code>var</code> returns to its saved state. It returns a <code>bool</code>: <code>true</code> when a snapshot existed and was restored, <code>false</code> 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()
}
}
```

View file

@ -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 <code>max(remaining, 0)</code>), to track a running high score, or to pick the greater of two candidate values. For the smaller of two values use <code>min</code>, and to bound a value on both sides at once use <code>clamp</code>.
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()
}
}
```

View file

@ -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 <code>min(current + heal, maximum)</code> — or to pick the lesser of two candidate values. For the larger of two values use <code>max</code>, and to bound a value between a low and a high at once use <code>clamp</code>.
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()
}
}
```

View file

@ -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 <code>Screen</code> API, and is most useful in <code>Start</code>- or <code>Update</code>-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() }
}
}
```

View file

@ -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 <code>new []T</code>, then <code>push</code> items onto it; afterward <code>len</code> 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])
}
}
}
```

View file

@ -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 <code>exit</code>. 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()
}
}
```

View file

@ -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 <code>-1</code> at end of input — the basis for headless, pipe-driven runs.
Reads a single byte from standard input and returns its value, or <code>-1</code> 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 <code>read_char</code> returns <code>-1</code>. Compare the byte against character literals such as <code>'a'</code> 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)
}
}
```

View file

@ -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 <code>cmd</code> 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")
}
}
```

View file

@ -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 <code>var</code> — 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 <code>var</code>. The compiler generates the serialization for you from your declarations, so you never write it by hand. Pair it with <code>load</code> 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()
}
}
```

View file

@ -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 <code>`{…}`</code> interpolation.
Converts a value to its textual form: an <code>int</code>, <code>bool</code>, or <code>fixed</code> becomes a string, and a value that is already a string is returned unchanged. This is what backtick <code>`{…}`</code> interpolation calls under the hood, so most of the time you can interpolate directly instead of calling <code>str</code> 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 <code>Screen.draw_text</code>, 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)
}
}
```

View file

@ -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 <code>ui</code> block, laying out its panels, labels, and buttons and loading any skins or images they reference. Call it once during a <code>Start</code>-phase handler, after loading any fonts the UI needs, and before you open a screen with <code>ui_open</code> or draw it with <code>ui_render</code>. 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()
}
}
```

View file

@ -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 <code>n</code> 32-bit words and returns a <code>words</code> value you can index with <code>buffer[i]</code> to read or write each word as an <code>int</code>. 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 <code>[]int</code> slice: the size is chosen up front and there is no <code>push</code>. For byte-granular storage use <code>bytes</code> 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)
}
}
```

View file

@ -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 <code>state</code> of the enclosing machine, or to another <code>scene</code>.
A <code>become</code> statement performs a transition. Inside a <code>machine</code>, `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")
}
}
}
}
```

View file

@ -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. <b>Each pass binds a fresh, immutable <code>name</code></b> — it is not a variable you reuse or reassign; the range <code>a .. b</code> runs from <code>a</code> up to but not including <code>b</code>.
The numeric <code>for … in</code> loop walks the half-open range `a .. b`, running the body for each value from `a` up to <b>but not including</b> `b`. <b>Each pass binds a fresh, immutable <code>name</code></b> — 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()
}
}
```

View file

@ -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 <code>if</code> 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 <b>no surrounding parentheses</b>, 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()
}
}
```

View file

@ -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 <code>in</code> 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
}
}
}
```

View file

@ -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 <code>var</code> (or register). It dispatches on the store's value.
A <code>machine</code> 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()
}
}
```

View file

@ -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 <code>match</code> 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
}
}
}
}
```

View file

@ -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 <code>machine</code>.
A <code>state</code> declares one state of an enclosing <code>machine</code>: 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")
}
}
}
}
```

View file

@ -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 <code>match</code>.

View file

@ -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 <code>while</code> 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()
}
}
```

View file

@ -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
---
<code>world_attach_dyn</code> 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)
}
}
}
```

View file

@ -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
---
<code>world_count</code> 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()
}
}
```

View file

@ -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
---
<code>world_detach_dyn</code> 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)
}
}
}
}
```

View file

@ -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
---
<code>world_field_id</code> resolves a field name <b>within</b> 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))
}
}
```

View file

@ -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.
<code>world_get</code> 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))
}
}
}
```

View file

@ -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
---
<code>world_has</code> 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") }
}
}
}
```

View file

@ -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
---
<code>world_kind</code> 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") }
}
}
}
```

View file

@ -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
---
<code>world_load</code> 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) }
}
}
```

View file

@ -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
---
<code>world_model_id</code> 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))
}
}
```

View file

@ -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
---
<code>world_prop_id</code> 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))
}
}
```

View file

@ -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
---
<code>world_query_next</code> 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)
}
}
}
```

View file

@ -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
---
<code>world_register_prop</code> declares a <b>brand-new</b> 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))
}
}
```

View file

@ -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
---
<code>world_save</code> 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))
}
}
}
```

View file

@ -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
---
<code>world_set</code> 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)
}
}
}
```

View file

@ -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
---
<code>world_size</code> 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()
}
}
```

View file

@ -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
---
<code>world_spawn</code> 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 <b>at runtime</b> 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))
}
}
}
```

View file

@ -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.
<code>attach</code> 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 <b>structural</b> change — it changes what the instance <i>has</i> — 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 }
}
}
}
}
```

View file

@ -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, <code>despawn self()</code> removes the current match.
A <code>despawn</code> 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() }
}
}
}
```

View file

@ -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.
<code>detach</code> removes a property from a live model instance, firing the property's `@OnDetach(Prop)` hook <b>before</b> 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() }
}
}
}
```

View file

@ -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 <code>disable Model</code> / a whole handler.
<code>disable</code> 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() }
}
}
}
```

View file

@ -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.
<code>enable</code> reverses a <code>disable</code>: 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() }
}
}
}
```

View file

@ -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 <code>for (a, b) in query […]</code>; a <code>{Tag}</code> filters without binding.
A <code>query</code> selects every live model instance that carries <b>all</b> 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()
}
}
```

View file

@ -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.
<code>self()</code> 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() }
}
}
}
```

View file

@ -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 <code>spawn</code> 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 } }
}
}
```

View file

@ -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 <b>veto</b> 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()
}
}
```

View file

@ -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 <b>decision, not just a notification</b>. A plain event tells listeners something happened; a `cancellable` event is fired <b>before</b> 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()
}
}
```

View file

@ -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 <b>expression</b> 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()
}
}
```

View file

@ -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 <b>open runtime surface a game exposes to mods</b>: 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()
}
}
```

View file

@ -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 <code>'w'</code>.
Returns the key currently pressed this frame as a character code, or <code>0</code> when no key is down. Compare the result against character literals such as <code>'w'</code>, <code>'a'</code>, <code>'s'</code>, <code>'d'</code>, or <code>' '</code> to drive movement and actions. Read it in an <code>Input</code>-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 <code>Input.key()</code> returns <code>0</code> (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()
}
}
```

View file

@ -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 <code>y</code> selects the row (from the top, starting at <code>0</code>), and each character of <code>cells</code> becomes the tile at that column. Call <code>Map.size</code> first, then one <code>Map.row</code> per row; the string length should match the map width. By convention <code>'#'</code> is a wall and <code>'.'</code> is open floor, but you may use any characters as your own tile codes and interpret them however you like with <code>Map.tile</code>.
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()
}
}
```

View file

@ -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 <code>Start</code>-phase handler before you fill the map with <code>Map.row</code>, 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 <code>Map.tile</code>, 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()
}
}
```

View file

@ -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 <code>(x, y)</code> and returns it as a character code, so you can compare it against literals like <code>'#'</code> or <code>'.'</code>. 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 <code>Map.size</code> answer <code>'#'</code>, 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()
}
}
```

View file

@ -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
---
<code>apply</code> is the exact inverse of <code>serialize</code>: it reads <code>len</code> bytes from <code>buf</code> 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 <code>net_poll</code> to reconcile a client to the authority's state, or after a rollback to restore a snapshot you took with <code>serialize</code>. 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
}
}
}
```

View file

@ -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
---
<code>is_owner</code> reports whether the local peer owns a given model instance — it compares the instance's stored owner (see <code>owner</code>) against <code>local_id</code> and returns the result. It only makes sense for instances of an <code>@Owned</code> model, and it is what <code>@Predicted</code> handler dispatch consults to decide whether the owning client should run a control handler speculatively. Prefer this over reading <code>owner</code> and comparing ids yourself. A freshly spawned, unowned instance returns false everywhere until the authority assigns it with <code>set_owner</code>.
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
}
}
}
```

View file

@ -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. <code>is_server()</code> is the authority check, <code>is_owner(e)</code> is true when this peer owns <code>e</code>, and <code>local_id()</code> is this peer's id. Prefer role annotations to sprinkling these through gameplay code.
<code>is_server</code> returns whether the local peer is currently acting as the authority. The networking runtime sets the peer's role register (via <code>set_role</code>); offline it defaults to server, so an un-networked build reports <code>true</code> and every role guard collapses to "run here." This is the low-level read behind the <code>@Server</code> handler annotation — and in ordinary gameplay code you should reach for the annotation instead, since scattering <code>is_server()</code> 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
}
}
```

View file

@ -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
---
<code>local_id</code> returns the network id assigned to the local peer by the runtime. It is the value <code>is_owner</code> compares an instance's <code>owner</code> 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 <code>0</code>. You rarely need it directly — <code>is_owner</code> 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()
}
}
}
```

View file

@ -9,4 +9,26 @@ tip: Receive queued bytes from the transport; returns the byte count.
order: 1
---
Copy up to <code>cap</code> waiting bytes into <code>buf</code> and return how many arrived (0 when nothing is queued). Pair it with the generated <code>apply</code> to fold an update back into the world.
<code>net_poll</code> copies up to <code>cap</code> waiting bytes into <code>buf</code> and returns how many actually arrived, or <code>0</code> when nothing is queued. It is the read half of the transport seam and the counterpart to <code>net_send</code>; each poll yields one datagram, so drain in a loop until it returns <code>0</code>. Once you have the bytes you fold them back into a model instance with the generated <code>apply</code> — for a self-describing frame, write the entity id into the first word on send and read it back here before applying. Like <code>net_send</code>, this is the freedom layer beneath <code>@Sync</code>; 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
}
}
}
```

View file

@ -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 <code>len</code> bytes from <code>buf</code> to <code>peer</code>. 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.
<code>net_send</code> hands <code>len</code> bytes from <code>buf</code> to the numbered <code>peer</code>, 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 <code>@Sync</code> and <code>@Owned</code> generate replication for you, and reach for <code>net_send</code> only when you drive the wire yourself, typically to ship a model instance's serialized <code>@Sync</code> fields. Pair it with <code>serialize</code> to fill the buffer and with <code>net_poll</code> 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
}
}
}
```

View file

@ -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
---
<code>@Owned</code> gives a model an owner slot; <code>owner(e)</code> reads the peer id that owns entity <code>e</code> and <code>set_owner(e, id)</code> assigns it (the authority decides). Ownership gates who may write <code>@Sync(to: owner)</code> fields.
<code>owner</code> returns the network peer id that owns a model instance, or <code>-1</code> when the instance is unowned. It only means anything for instances of an <code>@Owned</code> model, which is what gives the model its owner slot (the runtime's <code>@L_owner</code> array); a fresh instance starts unowned until the authority assigns it with <code>set_owner</code>. Ownership is part of the world snapshot, so it round-trips through replication and rollback. Read it when you need the raw id; use <code>is_owner</code> 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
}
}
}
```

View file

@ -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: <code>serialize</code> writes an entity's <code>@Sync</code> fields into <code>buf</code> and returns the length; <code>apply</code> folds received bytes back onto an entity. Together they are the replication substrate the sugar builds on.
<code>serialize</code> writes the replicated state of a model instance into <code>buf</code> and returns how many bytes it wrote. It is compiler-generated from the schema: it copies exactly the fields that are both <code>@Sync</code>-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 <code>net_send</code> or before a rollback; the inverse is <code>apply</code>, which reads back the identical layout, and <code>sync_size</code> 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)
}
}
}
```

View file

@ -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
---
<code>set_owner</code> writes the owning peer id into a model instance's owner slot; it is how the authority hands an <code>@Owned</code> model instance to a client. Because ownership gates who may write <code>@Sync(to: owner)</code> fields and who runs <code>@Predicted</code> 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 <code>@L_owner</code> 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
}
}
}
```

View file

@ -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 <b>value</b> and as an <b>assignment target</b>, 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)]
```

Some files were not shown because too many files have changed in this diff Show more