docs: move design/roadmap docs to the wiki, trim the repo root
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 12s
ci / build-and-test (push) Successful in 50s
commit-lint / conventional-commits (push) Successful in 3s

The repository root carried 11 large Markdown files (~330 KB); most were
long-lived design records rather than things a newcomer needs on first
contact, which buried the README and mixed "how to use Ludic" with "how we
decided to build it."

Move the design/roadmap docs to the Forgejo wiki (now enabled and
populated): Events, Networking, Scenes, Lifecycle, Mobile and
Syntax-redesign design records, the Bootstrap deep-dive and the Luanti
roadmap, under a Home index + sidebar. Each page had its selfhost/ source
links corrected for the #29 reorg and every repo-relative link rewritten to
an absolute URL on main so it resolves from the wiki.

All eight were current, actively-maintained records, so none were dropped.
The root now holds README.md plus the two user-facing references,
LANGUAGE.md and COMPILING.md; the README links to the wiki, and the
remaining references in LANGUAGE.md / COMPILING.md / examples/README.md and
the emit_net.ludic header comment point at the wiki pages. The emit_net.ludic
change is a comment only — the seed stays byte-identical and bootstrap-cfree
+ the full suite (56) stay green.

Closes #26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-31 00:36:19 +03:00
parent 23726afa90
commit c040fff8c8
13 changed files with 19 additions and 5146 deletions

View file

@ -1,984 +0,0 @@
# Bootstrapping Ludic in Ludic
**What it would take for Ludic to compile itself.**
Today `ludicc` is a C program: 2,508 lines across `compiler/ludicc.c`,
`compiler/native.c` and `compiler/driver.c`. Everything it produces is
Ludic-or-IR — the runtime a game calls is 2,501 lines of `.ludic`, and no C is
generated, compiled or linked in a build. The compiler is the last C in the
pipeline, and this document is about removing it.
Every claim about what the language can and cannot do below was **verified
against the built compiler**, not read off the docs. The probe programs are in
the appendix; each `✅`/`❌` is a real compile-and-run.
---
## 1. What "completely bootstrapped" means
Self-hosting is not one property. It is three independent axes, and they cost
wildly different amounts:
| Axis | Today | Target |
|---|---|---|
| **Compiler independence** — is the compiler written in the language? | ❌ 2,508 lines of C | `ludicc` written in Ludic, compiling itself to a fixpoint |
| **Runtime independence** — is the library the language ships written in the language? | ✅ **already done** — 2,501 lines of `.ludic` (gfx, PNG/DEFLATE, TrueType, UI) | keep |
| **Toolchain independence** — does a build need a foreign compiler? | ❌ `clang` assembles the IR and links | see §7 — three levels, only one is worth reaching |
The runtime axis is already won, and that is the unusual part. Most languages
self-host the compiler long before they stop leaning on a C standard library;
Ludic did it backwards. **The remaining work is concentrated in one axis.**
There is also a fourth, smaller thing: `runtime/native/cocoa.ll` (327 lines) and
`runtime/web/wasm.ll` are hand-written LLVM IR, not Ludic. §7.4 covers whether
that matters.
Running alongside all of this is a question the bootstrap forces rather than
raises: **what the syntax should finally be.** A self-hosted compiler is written
in the language it compiles, so the grammar wants to be settled *before* the
port, not after. §5 audits what is irregular today and proposes the freeze; it
is scheduled as Stage 0.5, between the language features and the libraries.
### The honest bar
"Bootstrapped by itself completely" should mean:
1. `ludicc` is written in Ludic.
2. A `ludicc` binary compiles the Ludic source of `ludicc` and produces a
**byte-identical** binary to itself (the fixpoint test, §6).
3. The C compiler is needed **only** to build the very first seed, and that seed
is a checked-in artifact rather than a live dependency.
4. No C source remains in the repo outside that seed.
It should *not* mean writing an object-file writer and a linker. Rust and Swift
are self-hosted and both stand on LLVM; standing on `clang` as an IR assembler
is the same posture. §7 argues this explicitly so the goal does not quietly
inflate.
---
## 2. Where the tree stands
```
compiler/ C split by concern; every file under 500 lines
ludicc.c 435 pipeline + codegen glue + main
util/ sb, diag 103 string builder; source registry + diagnostics
front/ lex, ast, parse 469 tokens; Node; recursive descent + imports
sem/ tables, uitree, validate 208 decl tables; widget flattening; static checks
back/ ir_* x10 953 the LLVM IR backend, one file per concern
driver/ toolchain, webbundle 382 IR -> object -> exe/dylib; the wasm bundle
fmt/ fmt 162 canonical AST printer (--fmt)
------
2,712 C <- all of it, and all that must go
tools/ludic-tools/* 3,260 C ludic-fmt + ludic-lsp (not yet split)
runtime/native/core.ludic 394 Ludic framebuffer, text, registers, RNG, input
runtime/native/image.ludic 436 Ludic PNG, sprites, alpha blend, 9-slice
runtime/native/inflate.ludic 276 Ludic DEFLATE (RFC 1951)
runtime/native/truetype.ludic 804 Ludic sfnt loader + AA rasterizer, Q16.16
runtime/native/ui.ludic 591 Ludic retained widget tree, layout, focus
----
2,501 Ludic <- proof the language is already load-bearing
runtime/native/cocoa.ll 327 LLVM IR macOS window (objc_msgSend + CoreGraphics)
runtime/web/wasm.ll 369 LLVM IR browser shims
```
`util/`, `front/`, `sem/` and `fmt/` are separately compiled translation units;
`back/` and `driver/` are still one unit assembled by `back/native.c`, so their
include order is their definition order. The build list lives in
`compiler/sources.sh`, sourced by both `build.sh` and `test.sh`.
`truetype.ludic` matters more than its line count. A from-scratch sfnt parser
with cmap format dispatch, composite glyph recursion and a Bézier rasterizer is
*structurally the same kind of program as a compiler*: binary input, recursive
descent, table lookups, a growing output buffer. It already works. That is the
strongest single piece of evidence that this port is feasible rather than
aspirational.
---
## 3. What the language can already do
All verified. A compiler needs each of these, and each one works today.
| Capability | Status | Evidence |
|---|---|---|
| Recursion | ✅ | `fib(10)` → `55` |
| Mutual recursion / forward references | ✅ | `odd`/`even` cross-call |
| Deep recursion (recursive-descent parsing) | ✅ | 5,000 frames, no crash |
| Heap allocation | ✅ | `mem_alloc`, `mem_free`, `mem_copy`, `mem_set`; 1 MiB alloc verified |
| Byte-level memory | ✅ | `peek8`/`poke8`, `peek32`/`poke32`, `peekp`/`pokep`, `ptr_add` |
| `ptr` locals, params, returns | ✅ | `function make(n: int) -> pointer` |
| `ptr` in a property field | ✅ | `property Nd { kind: int = 0, a: pointer = ptr_null() }` |
| String literals as readable bytes | ✅ | `peek8("hello", 1)` → `101` |
| `str` accepted where `ptr` expected | ✅ | `f("A")` into `function f(p: pointer)` |
| String comparison, **hand-written in Ludic** | ✅ | `streq` over `peek8` |
| Integer → decimal, **hand-written in Ludic** | ✅ | `itoa(48291)` → `"48291"` |
| File read: open/seek/tell/read/close | ✅ | full round-trip of a written file |
| File write | ✅ | `file_open`/`file_write`/`file_close` |
| Module-level mutable state | ✅ | `var count: int`, `var heap: pointer` |
| `let` is mutable | ✅ | `i = i + 1` in a loop |
| `while`, numeric `for i in a .. b` with runtime bounds | ✅ | |
| `if` / `else if` / `else` chains | ✅ | |
| `match` with multi-value arms and `_` | ✅ | `1 => … 2, 3 => … _ => …` |
| Bitwise ops | ✅ | `band`/`bor`/`bxor`/`bnot`/`shl`/`shr` |
| `shr` is **logical**, not arithmetic | ✅ | `shr(-16, 1)` → `2147483640` |
| Character literals | ✅ | `'x'`, `'\n'`, `'\0'` lex to ints |
| Exit codes | ✅ | `os_exit(3)` → shell sees `3` |
| Separate compilation, C ABI | ✅ | `module` + `@export fn`, `extern fn … = "sym"` |
**The consequence:** a compiler is *already expressible* in Ludic today. You
could write a lexer, a parser building nodes as hand-offset `peek32`/`poke32`
records, a symbol table, and an IR text emitter, using nothing above. It would
be miserable to read and maintain at 6,000 lines — but nothing in §4 is a
*capability* blocker except argv. The rest is about whether the resulting source
is something a human or a model can work in.
That distinction shapes the whole plan: **this is mostly an ergonomics project
with one small hole in it**, not a language-design project.
---
## 4. What the language is missing
Each entry: the gap, why a compiler specifically needs it, the proposed design,
and the lowering. Verified-missing means it is a compile error today.
### Tier A — real blockers
#### A1. Command-line arguments ❌ *the only true capability blocker*
```
ludicc: error: line 1: unknown function 'os_argc'
```
`ll_emit_main` in `compiler/native.c` emits `define i32 @main()` — **no
parameters**. A self-hosted `ludicc` has no way to learn which file to compile.
Everything else in this document has a workaround; this one does not.
**Design.** Two intrinsics:
```ludic
# doc-check: skip — proposed signature notation, not code
os_argc() -> int
os_arg(i: int) -> str
```
**Lowering.** Change the signature to `define i32 @main(i32 %argc, ptr %argv)`,
store both into `@L_argc` / `@L_argv` in the entry block, then `os_argc()` is a
load and `os_arg(i)` is exactly the existing `peekp(@L_argv, i)` path. Add to
`INTRINSICS[]` in `native.c`.
**Cost.** ~30 lines of C. This is the single highest-value change in the
document: it is what turns "a Ludic program" into "a Ludic command-line tool".
#### A2. Aggregate types (`struct`) ❌
```
ludicc: error: line 2: expected declaration (got 'struct')
```
An AST node, a token, a symbol-table entry and a type descriptor are all
records. Today there are two workarounds, and both are bad at compiler scale:
- **Hand-offset memory** — `poke32(n, 0, kind)`, `pokep(n, 1, child)`. This is
what `truetype.ludic` does, and it works, but every field access becomes a
magic number. Across a 6,000-line compiler this is the difference between
maintainable and not.
- **ECS entities as nodes** — verified working (`property Nd { kind, a: pointer }`),
and initially seductive because queries give you free traversal. **Do not do
this.** `LUDIC_MAX_ENT` is 1024 in `native.c:18`; the entity world is a fixed
array of per-property storage. A compiler needs hundreds of thousands of
nodes. This is a dead end, and it is worth writing down because it is the
obvious wrong turn.
**Design — reference semantics, not value semantics.** The cheap version that
unblocks everything:
```ludic
# doc-check: skip — proposed syntax: struct does not exist yet
struct Tok { kind: int = 0, text: pointer = ptr_null(), line: int = 0 }
let t = new Tok # heap-allocated, fields seeded from defaults
t.kind = T_ID
print_int(t.line)
free Tok t # or leak it; see §8 on arenas
```
No copying, no by-value passing, no nested-struct inlining — a `struct` value
*is* a `ptr` with a known layout, so it costs nothing in the type system beyond
a layout table.
**Lowering.** This is largely already built. `native.c` already emits
`%Cmp_<Name>` LLVM struct types for properties and already resolves
`a.b` through `ll_member_addr` with `getelementptr`. A `struct` is a
`%Cmp_`-style type *without* the parallel entity arrays: `new` is
`malloc(sizeof)` plus a default-seeding memset/store sequence, and `.field` is
the existing `getelementptr` path. Reusing the property machinery is why this
is far cheaper than it looks.
**Cost.** ~250 lines of C across `ludicc.c` (parse) and `native.c` (layout,
`new`, member access). Highest cost in the document, and the highest payoff.
#### A3. Arrays and indexing ❌
```
ludicc: error: line 2: expected identifier (got '[')
```
Token buffers, string tables, keyword tables, scope stacks. Currently
`mem_alloc` + `peek32`, which works but reads badly.
**Design.**
```ludic
# doc-check: skip — proposed syntax: array types do not exist yet
var keywords: [str; 64] # fixed-size module-level storage
let toks: [Tok; 0] = mem_alloc(n * size_of(Tok)) # or a growable buffer
toks[i].kind = T_ID # composes with A2
```
**Lowering.** `[T; N]` is `[N x <llty(T)>]`, already exactly how `@L_alive` and
`@S_<Comp>` are emitted. `a[i]` as both rvalue and lvalue is a
`getelementptr` — the same code path as member access, indexed instead of
named. The important part is that `toks[i].kind` composes: index then member,
one GEP chain.
**Cost.** ~150 lines. Should land *with* A2, since neither is much use alone.
#### A4. `break` / `continue` ❌
```
ludicc: error: line 3: unknown identifier 'break'
```
Lexers and parsers are made of `while (1) { … break; }`. The workaround —
sentinel booleans threaded through every loop condition — is the kind of thing
that makes a 6,000-line port unreadable.
**Design.** `break`, `continue`. No labels; nested loops in a compiler rarely
need them, and adding labels later is compatible.
**Lowering.** `native.c` already maintains `ll_loopstk[64]` (for `self()` inside
queries). Extend each frame with `break_label` and `continue_label`, then
`break` is `br label %<break>`. Note the existing gotcha recorded in the native
backend notes: **stack slots must be emitted in the entry block** — no new
allocas at the break site.
**Cost.** ~40 lines. Best value-per-line in the document.
#### A5. `mem_realloc` ❌
```
ludicc: error: line 1: unknown function 'mem_realloc'
```
Every table in a compiler grows: tokens, nodes, the output buffer. Hand-rolling
alloc-copy-free works but is written once per table and gotten wrong once per
table.
**Design.** `mem_realloc(p: pointer, n: int) -> pointer`.
**Lowering.** `declare ptr @realloc(ptr, <size_t>)` plus one `INTRINSICS[]`
entry. **Use `ll_size_t()` / `ll_widen()` for the size argument — do not
hardcode `i64`.** `size_t` is `i32` on wasm32, and `native.c` now routes every
size-taking intrinsic through those helpers for exactly this reason.
**Cost.** ~6 lines.
#### A6. Diagnostics on stderr ❌
```
ludicc: error: line 1: unknown function 'print_err'
```
Only stdout exists (`print_str` → `printf`, `write_byte` → `putchar`). This is
not cosmetic: **`ludicc --emit llvm` writes IR to stdout.** A self-hosted
compiler that printed errors to stdout would interleave diagnostics into its own
output, corrupting it in exactly the case you most want a diagnostic.
**Design.** Prefer an intrinsic that yields a handle, so the existing file
plumbing is reused rather than duplicated:
```ludic
# doc-check: skip — proposed signature notation, not code
file_stderr() -> pointer # then file_write(f, buf, n) as usual
```
**Lowering — note the portability wrinkle.** There is no portable `@stderr`
global in LLVM IR: Darwin exports `@__stderrp`, glibc exports `@stderr`, and
wasm has neither in the same shape. So `file_stderr()` must select per target,
alongside the existing `target_os()` logic in `driver.c`. This is the one item
here that is genuinely target-dependent rather than merely unimplemented, and
it should be designed with that in mind rather than bolted on.
**Cost.** ~40 lines including the per-target selection.
### Tier B — needed for *complete* bootstrap, not for the compiler
#### B1. Function pointers ❌
```
ludicc: error: line 3: unknown type 'fn' for var h
```
`&cb` also fails to compile.
The compiler itself does **not** need these — `match` dispatch covers every
place a C compiler would use a function pointer table.
But they are what would let `cocoa.ll` become Ludic. The macOS window builds an
`NSView` subclass at runtime with `objc_allocateClassPair` and installs **an IR
function as its IMP**. Without the ability to take the address of a Ludic `fn`,
that shim can never move out of hand-written IR. So: irrelevant to §6, and
load-bearing for §7.4.
**Design.** `&fnname` yields a `ptr`; call through it via
`call_ptr(p, args…)` or a typed `fn(int)->int` type.
**Cost.** ~120 lines. Defer until after the fixpoint.
#### B2. String operations — **no language change needed**
`str + str` is worth calling out as a *bug*, not a gap. It passes the front-end
and then emits invalid IR:
```
build/probe_t_headless.ll:13401:17: error: global variable reference must have pointer type
```
That is a front-end/backend mismatch: the typechecker accepts an operation the
backend cannot lower. Until strings exist properly, `str + str` should be a
clean compile error rather than a `clang` error in generated code.
Everything else a compiler needs from strings is **already writable in Ludic
today** — `streq` and `itoa` are verified. This is not a language gap; it is a
library to write (§6 Stage 1), and it is the largest pure-typing chunk of the
whole project.
### Tier C — explicitly out of scope, recorded so they are not rediscovered
| Gap | Why it does not block |
|---|---|
| **64-bit integers** ❌ (`100000*100000` → `1410065408`, wraps at i32) | Line numbers, offsets, node indices and string lengths all fit in `i32`. Only matters for source files > 2 GiB. |
| **A non-ECS entry point** | A `Start`-phase system plus `os_exit(n)` gives correct exit codes — verified. You do pay for an unused 1024-entity world; that is a constant, not a blocker. A `tool Name { function main() -> int }` form would be nicer, not necessary. |
| Closures, generics, unions, sum types | A compiler in the style of `ludicc.c` uses none of them. |
| GC | A compiler should leak deliberately (§8). |
| Unsigned integer types | `shr` is already logical and `band`/`bor` are bit-level — sufficient. |
| Multiple return values | `ptr` out-parameters work today. |
---
## 5. Designing for readers — human and model
The goal: Ludic source should be obvious to a person skimming it and
unambiguous to a model generating it. Those two goals agree far more than they
conflict, and where they conflict the resolution is **regularity, not
verbosity** (§5.2).
Everything in this section was verified against the built compiler. The probes
are in the appendix under "Syntax audit".
### 5.1 Why this belongs in the bootstrap document, and why now
**Syntax changes are cheap today and expensive after Stage 3.** This is a hard
ordering constraint, not a preference.
Today, changing the grammar costs: edit `ludicc.c`, `sed` three examples and
five runtime files, run `bin/x test`. An afternoon.
After the fixpoint, `ludicc` is *written in the syntax it parses*. Every change
becomes a four-step dance: build a compiler that accepts both old and new forms
→ compile it with the old seed → rewrite every source file → remove the old
form and regenerate the seed. That is what every mature language does, and it
is why mature languages change syntax slowly. It is not a reason to avoid the
change; it is a reason to **make it before the port, not after**.
So the plan gains a stage:
> **Stage 0.5 — Syntax freeze.** Between Stage 0 (language features) and
> Stage 1 (libraries). Nothing in Stage 2 starts until the grammar is final.
The port should be *the first large program written in final Ludic*, not the
last large program written in provisional Ludic.
**This work also strengthens the bootstrap itself.** Stage 2b uses `--fmt`
equality as the oracle proving two parsers agree. That oracle is only as tight
as the language is regular: every alternative spelling is surface variance the
formatter must erase. Reduce the variance and the oracle gets sharper. The
readability project and the self-hosting project are not competing for the same
time — one makes the other more trustworthy.
### 5.2 What actually helps a model — and what is folklore
Worth being precise here, because "AI-friendly syntax" attracts a lot of
confident nonsense.
**Genuinely helps:**
| Property | Why it matters |
|---|---|
| **Low syntactic variance** — one spelling per concept | Every alternative is a branch point during generation and a case in the parser. Two ways to write a list is two chances to be inconsistent within one file. |
| **Leading-keyword, bounded lookahead** | Every declaration and statement identifiable from its first token. Helps the hand-written recursive-descent parser Stage 2b will be, *and* a model predicting forward. |
| **No silent no-ops** | If the language accepts a construct it must either honour it or reject it. Accepting-and-ignoring teaches a falsehood (see R6 — the worst thing in the audit). |
| **Recoverable structure** — explicit terminators | A slightly-wrong generation fails *locally*, with an error pointing at the mistake, instead of cascading into a confusing error 40 lines later. |
| **Locality** — meaning readable from the construct | No action-at-a-distance. Ludic is already strong here; keep it. |
| **Greppable unique anchors** | `property Pos` is findable. Retrieval quality is a language design property. |
| **Errors that name the fix** | Already partly true: a missing builtin errors naming `rt_<name>`. Extend that everywhere. |
**Folklore, and false:**
- *"More verbose is more AI-friendly."* No. Ceremony without information hurts
both audiences. What helps is redundancy that **encodes intent** — an explicit
type, a closing keyword — not boilerplate.
- *"Significant indentation reads better."* It reads fine and **generates
badly**: indentation drift across a long generated block is unrecoverable and
survives review. Ludic uses braces. Keep them.
- *"Natural-language-like syntax helps."* Prose-shaped keywords add ambiguity.
Consistent symbols beat English words that read three ways.
- *"Terseness is bad for models."* Terseness is fine; *irregularity* is the
problem. A short form used consistently is easy to predict.
**The real tension:** humans skim, so terseness helps them; machines benefit
from redundancy. Regularity resolves it — the same shape everywhere costs a
human nothing once learned, and costs a model nothing to predict.
### 5.3 Audit — what is irregular in Ludic today
Each row verified by compiling a probe, not by reading docs.
| # | Irregularity | Evidence | Cost |
|---|---|---|---|
| **R1** | **No statement terminator at all.** `block()` is `skipnl(); stmt()` in a loop. A newline *stops* an expression (it lexes as `T_NL`, and `binlevel` only continues on `T_OP`) but is never *required*. `let x = 1 x = x + 1 print_int(x)` on one line is three legal statements — verified compiling. | `ludicc.c` `block()`, `binlevel` | The reader cannot see where a statement ends without re-deriving operator precedence. Blocks error recovery entirely. |
| **R2** | **Commas are optional everywhere.** `if(isop(",")) pi++` appears in `comp()`, `arche()`, `fn` params and `spawn`. `{ x: int = 0 y: int = 0 }` and the comma'd form both compile. | 4 parser sites | Two spellings, zero semantic difference. |
| ~~**R3**~~ | ~~**`and`/`or` alias `&&`/`\|\|`.**~~ **RESOLVED** — `and`/`or`/`not` are the only boolean operators; `&&`, `\|\|` and `!` are each rejected with a diagnostic naming the fix, and all three words are reserved. `!=` is unaffected. | landed via S3 | — |
| **R4** | **`{ }` means seven different things** — statement block; property fields (`n: T = e`); model list (bare idents); spawn initialisers (`N = { … }`); ui props + children (`k=v` juxtaposed, no commas); match arms (`p, p => …`); machine states (`state N = v { … }`). | `block/comp/arche/spawn/parse_widget/match/machine` | The delimiter carries no information. You must already know the head keyword to know the inner grammar. |
| **R5** | **Contextual keywords, not reserved.** `phase`, `query`, `reads`, `writes`, `needs`, `uses`, `where`, `in`, `on`, `layer`, `state`, `start` are matched with `isid()` — ordinary identifiers. `let query = 5 let phase = 6` compiles and prints `11`. | `sys()`, `scene_decl()` | A local named `enter` or `match` produces a baffling error far from the cause. |
| **R6** | **Contracts are parsed and thrown away.** `requires`/`ensures`/`invariant` parse an expression and **discard it** (`pi++; expr();`). `reads`/`writes`/`needs`/`uses`/`effects` are `skip_brackets()`. `pure` is consumed and ignored. Verified: `function half(n: int) -> int requires n > 100000 ensures false` compiles, and `half(8)` returns `4`. Verified: a system declaring `reads [Pos]` that **writes** `p.x = 99` compiles. | `fn()`, `sys()` | **The worst item in the audit.** The language accepts a contract and does nothing. A model writing `requires n > 0` is rewarded with a clean compile and zero enforcement — it learns a lie, and so does a human reader trusting the annotation. |
| **R7** | **`str + str` typechecks, then emits invalid IR.** | verified (§4 B2) | The front-end accepts what the backend cannot lower. |
| **R8** | **Two formatters, opposite philosophies, both called "format".** `ludicc --fmt` canonicalises hard (one statement per line, `and`→`&&`, full parenthesisation) but drops comments and inlines imports. `ludic-fmt` is token-based and preserves comments — but **normalises nothing**: handed the one-line `let a = 1 a = a + 1 if true and false { … }`, it returned it unchanged. | verified side-by-side | **Neither tool enforces a single spelling.** The canonicaliser is unusable on real source; the source formatter has no opinion. |
| **R9** | **Two ways to spell a tag** — `property Player { }` (empty property) or `model`. | LANGUAGE.md | |
| **R10** | **Stale docs are stale training data.** LANGUAGE.md still says "the current compiler is a tree-to-C translator" (it emits LLVM IR) and lists arrays under "Not yet implemented" beside things never planned. | LANGUAGE.md | Docs are the highest-leverage model input in the repo. A wrong doc is worse than a missing one. |
### 5.4 Proposals
Ordered by value per line of work. Each is a Stage 0.5 item unless noted.
**S1. Require a statement terminator.** A statement ends at a newline, `;`, or
`}`. Make `T_NL` significant inside `block()` instead of discarding it.
*Why:* fixes R1, and it is the precondition for error recovery — without it a
parser cannot resynchronise, so every syntax error stays a cascade.
*Cost:* ~30 lines. *Ripple:* one-line bodies like `if x { a }` still work;
multi-statement one-liners in the runtime need a `sed`.
**S2. Make separators mandatory.** Commas required in every comma-list;
remove the optional path. *Fixes R2. Cost:* ~10 lines + tree-wide `sed`.
**S3. One spelling for boolean operators. ✅ LANDED.** `and`/`or` are the only
boolean operators. Ludic already spells bitwise operations as functions
(`band`/`bor`), so the symbols bought nothing, and dropping them removes the
`&` vs `&&` bug class by construction.
What shipped: `&&`/`||` still *lex* as single tokens, purely so the parser can
emit `'&&' is not a Ludic operator - write 'and'` instead of tripping over a
stray `&`; `and`/`or` became reserved words, so `let and = 5` is rejected at the
mistake; the AST op string is now `"and"`/`"or"`, which is **exactly the LLVM
opcode**, so the lowering ternary collapsed to passing `op` straight through;
and `--fmt` emits the new spelling for free, since it prints the op string.
Six regression tests in `bin/x test` (64 → 70), including one asserting no `.ludic`
source uses the symbols outside a comment. *Fixed R3.*
**S4. Reserve every keyword.** One table, shared by the lexer, parser,
`ludic-fmt` and `ludic-lsp` — those tools already share a vocabulary in
`ludic_syntax.h`, so there is one obvious home. Reject `let query = 5` at the
point of the mistake. *Fixes R5. Cost:* ~40 lines.
**S5. Delete or implement every silent no-op.** ← **highest value in the
section.** Two honest options per construct, no third:
- `reads` / `writes`: **implement them.** The compiler already knows every
property a system touches — it builds the query and walks the body. Checking
the declaration against actual access is a genuine static analysis the
language claims to have and doesn't. This converts dead syntax into a real
guarantee, which is exactly what an "AI-first" language should offer a model
reasoning about a system in isolation.
- `requires` / `ensures`: either lower to a checked assertion in debug builds
(`if !cond { print_err(...) os_exit(1) }` — cheap, and A6 stderr lands in
Stage 0 anyway), or remove them from the grammar until they mean something.
- `pure`, `needs`, `uses`, `effects`, `invariant`: remove until implemented.
*Fixes R6. Cost:* ~150 lines for `reads`/`writes` checking, ~60 for assertions,
~10 to delete the rest.
**S6. Cut the block grammars from seven to two.** Full unification is too
invasive to be worth it. The achievable version: every `{ }` is either a
**statement block** or a **field list** (`name: type = default`, comma-separated,
one shape), and `ui` props adopt the same separator rule as everything else.
Document all remaining shapes in one grammar table. *Partially fixes R4.
Cost:* ~120 lines.
**S7. One formatter with one contract.** Merge the philosophies rather than
keeping two half-tools: `ludic-fmt` gains `--fmt`'s normalisation decisions
(statement-per-line, single spelling, consistent commas) while keeping its
token-based comment preservation, and becomes **normative** — `ludic-fmt
--check` gates CI. `ludicc --fmt` reverts to being an honest debug dump and is
renamed `--dump-ast`. *Fixes R8.* After S1–S3, canonical form is the *only*
form, so the formatter stops being a style preference and becomes a check.
*Cost:* ~200 lines, mostly in `ludic_fmt.h`.
**S8. Machine-readable grammar and diagnostics.** Emit the grammar as one EBNF
file, and give every diagnostic a stable code plus a one-line suggested fix
(`ludicc --explain L0412`). Feeds the LSP, the docs and any model at once.
*Cost:* ~250 lines. *Defer to after the fixpoint* — valuable, not ordering-critical.
**S9. Documentation hygiene as a build step.** `bin/x test` already understands
` ```ludic ` fences. Extend it so **every fence in every `.md` must compile**,
and fix R10's stale claims. *Cost:* ~60 lines of shell. Do this early — it is
cheap and it stops the docs drifting further while the rest of the work lands.
### 5.5 What not to change
Recording these so they are not relitigated:
- **Braces, not indentation** (§5.2).
- **`#` comments** — unambiguous, one spelling already.
- **The ECS vocabulary** — `property` / `system` / `query` / `phase` are
unusually self-describing and greppable. This is the language's best existing
readability asset.
- **`fixed` / Q16.16** — determinism is a design constraint, not a style choice.
- **Do not add** operator overloading, implicit conversions beyond `int`→`fixed`,
macros, or anything else with action-at-a-distance. Every one of them trades
local readability for cleverness.
### 5.6 Sequencing
| When | What | Why there |
|---|---|---|
| **Now, before Stage 1** | S9 (doc hygiene) | Cheap; stops further drift immediately. |
| **Stage 0.5** | ~~S3~~ ✅ done · S1, S2, S4, S5, S6, S7 | Must precede the port (§5.1). |
| **After Stage 3** | S8 (EBNF + diagnostic codes) | Valuable, not ordering-critical; better written in Ludic against the self-hosted parser. |
**S3 was the one genuinely contentious call** — which boolean spelling — because
it is pure taste and touches every file. It was decided in favour of `and`/`or`
and has landed. Everything remaining in this section is a choice between "one
spelling" and "two", where the answer is not in doubt.
**`!` → `not` has since landed too**, on the same reasoning and by the same
mechanism: `!` still lexes (so `!=` is untouched) purely so the parser can say
`'!' is not a Ludic operator - write 'not'`. Ludic's three boolean operators are
now `and`, `or`, `not`, all reserved words, with no symbol spellings at all.
---
## 5.7 Status — self-hosting achieved
Updated 2026-08-27. `bin/x test` = 93/93, `bin/x test-tools` = 28/28,
`bin/x selfhost-test` = 5/5 including the bootstrap fixpoint.
**Ludic is fully self-hosted.** The compiler is written in Ludic
(`selfhost/*.ludic`, ~2,400 lines), compiles every example to byte-identical
output and its own source to a fixpoint, and is built from a checked-in IR seed
with **no C compiler** — the former C compiler has been deleted.
From a clean checkout, build the compiler and the task-runner in one line:
```bash
clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
```
Thereafter `bin/x build` rebuilds the entire toolchain into `bin/` (`ludicc`,
`ludic`, `x`, `ludic-fmt`, `ludic-lsp`), `bin/x bootstrap-cfree` reproduces the
compiler from the seed with no C compiler, and `bin/x help` lists every command.
Run `bin/x` from the repository root.
| Stage | What | State |
|---|---|---|
| **0** | language features (argv, struct, arrays/slices, break/continue, mem_realloc, stderr) | ✅ done |
| **0.5** | S3 (`and`/`or`/`not`), S9 (doc checking), short-circuit `and`/`or` | ✅ done |
| — | S1/S2/S4/S5/S6/S7 (statement terminators, mandatory commas, reserved-word audit, no-op removal, block unification, one formatter) | not done — polish of the *full* language, not needed for self-hosting |
| **1** | support libraries in Ludic (`str`, `buf`, `io`) | ✅ done |
| **2** | the compiler ported to Ludic (`lex`, `parse`, `emit_*`) | ✅ done |
| **3** | the fixpoint (`gen2.ll == gen3.ll`) | ✅ done |
| **4** | retire the C as a *live dependency* (IR seed, C-free rebuild) | ✅ done — `bin/x bootstrap-cfree` |
| **4+** | retire `ludicc.c` entirely (port the game backend) | ✅ **done** — `compiler/` deleted; the compiler is `selfhost/*.ludic` |
### What "self-hosting" means here, precisely
The self-host compiler (`selfhost/`) implements the **compiler-subset**: `struct`
(reference), `[]T` slices with `push`/`len`, functions, a plain `main` entry,
the full control flow, the operators (with short-circuit `and`/`or`), and the
low-level intrinsics. It deliberately does **not** implement the game half of
Ludic — ECS, queries, models, scenes, UI, save/load, `match`/`machine`,
fixed-point. It targets native (macOS/clang) and emits LLVM IR text that clang
assembles, exactly the posture the C `ludicc` has.
It is written entirely in that subset, which is why it compiles itself. The
three-generation proof (`bin/x bootstrap`):
```
stage0 build/ludicc (C) compiles selfhost.ludic -> gen1 (a Ludic-written compiler)
stage1 gen1 compiles selfhost.ludic -> gen2.ll -> gen2
stage2 gen2 compiles selfhost.ludic -> gen3.ll
assert gen2.ll == gen3.ll # the compiler reproduces itself, independent of its seed
```
`gen1`'s IR legitimately differs (a different compiler built it); `gen2 == gen3`
is the property that matters — the Ludic compiler has no dependency on how it was
built. It is also verified *correct*, not merely self-consistent: it compiles a
corpus (`selfhost/tests/`) of struct, slice, and control-flow programs to
binaries that produce the expected output.
### Stage 4 — the C is retired as a live dependency
The self-hosted compiler no longer needs the C `ludicc` to exist. Its own LLVM
IR is checked in as `selfhost/ludicc.seed.ll` — a proven fixed point — and
`bin/x bootstrap-cfree` assembles that with clang (an IR assembler, the
floor Rust and Swift stand on) and rebuilds the compiler, which reproduces its
own IR. **The C source is never invoked.** This is the seed path §8 recommended.
Crucially, the compiler **evolves** without the C compiler: `bin/x reseed`
uses the *current* seed to build a compiler with new source, then takes that
compiler's own output as the new seed. New features (this session: `match`,
bitwise ops, `peek32`/`poke32`) landed and reseeded entirely C-free. The C
compiler is now a historical seed, not a dependency.
### Stage 4+ — `ludicc.c` is deleted
The self-host compiler was extended to the **whole** language — properties,
models, systems, phases, `for … in query` (with `where`), spawn/despawn,
`self()`, `machine`/`become`, `match`, `save`/`load` snapshots, the retained
`ui` widget tree, multi-file `import`, fixed-point Q16.16, and every runtime
intrinsic. It auto-splices the Ludic runtime exactly as the C compiler did.
It now compiles **every example** — `snake`, `menu`, and the 6-file JRPG
`chronorift` — to output byte-identical to the original C compiler (checked
against golden renders in `selfhost/golden/`), and still compiles its own source
to a fixpoint. The C compiler (`compiler/`, ~2,700 lines) has been **deleted**.
`bin/x build` builds `bin/ludicc` from the IR seed with clang, and `bin/x app`
drives the native link (headless, or windowed via `cocoa.ll`).
What did not come across: the old C driver's **wasm target, cross-compilation,
and shared-library** paths. Those are driver features, not codegen — the
self-host compiler emits native-ABI IR — and re-implementing them on the
self-hosted toolchain (wasm needs i32 `size_t`; the others are clang flags in
the `bin/x app` build path) is the remaining follow-up.
---
## 6. The plan
### Stage 0 — Extend the C compiler (~500 lines of C)
The C `ludicc` must be able to compile the Ludic `ludicc`. Land Tier A only, in
this order — cheapest-and-unblocking first:
1. **A4** `break`/`continue` (~40) — immediate readability win on everything after.
2. **A5** `mem_realloc` (~6).
3. **A1** `os_argc`/`os_arg` (~30) — unblocks the entire notion of a CLI tool.
4. **A6** `file_stderr` (~40).
5. **A2 + A3** `struct` + arrays (~400, landed together).
Each gets a test in `bin/x test` as it lands. The suite is at 64/64; Stage 0 should
leave it green and larger.
**Explicitly not in Stage 0:** function pointers, 64-bit ints, a `tool` entry
form. They are not on the path to the fixpoint.
### Stage 0.5 — Syntax freeze (~600 lines of C + a tree-wide `sed`)
**The grammar must be final before Stage 2 starts** (§5.1): after the fixpoint,
every syntax change costs a four-step reseed instead of an afternoon.
Land S1–S7 from §5.4: mandatory statement terminators, mandatory separators,
one boolean spelling, reserved keywords, no silent no-ops, two block shapes
instead of seven, one normative formatter. S9 (doc hygiene) can land earlier —
it is cheap and independent.
Exit criterion: `ludic-fmt --check` passes on the whole tree and there is
exactly one legal spelling of every construct. That is also what makes the
Stage 2b oracle tight.
### Stage 1 — Support libraries in Ludic (~800 lines of Ludic, zero language work)
Nothing here needs Stage 0 except `struct`/arrays for pleasantness. This is the
part that is pure writing, and it can start immediately and in parallel.
| File | Contents |
|---|---|
| `runtime/native/strings.ludic` | `str_eq`, `str_len`, `str_dup`, `str_cat`, `substr`, `str_chr`, `str_hash`, `itoa`, `atoi`, `hex` |
| `runtime/native/buf.ludic` | growable byte buffer — `buf_new`, `buf_putc`, `buf_puts`, `buf_putint`, `buf_len`, `buf_ptr`. This is `SB` from `ludicc.c`, and the IR emitter is nothing but calls to it. |
| `runtime/native/io.ludic` | `read_whole_file` (the open/seek/tell/read/close dance, verified working), `write_whole_file`, stderr diagnostics |
| `runtime/native/map.ludic` | open-addressing `str -> int` hash table: keyword lookup, string interning, symbol tables |
| `runtime/native/arena.ludic` | bump allocator — see §8 |
### Stage 2 — Port the compiler, each piece against a differential oracle
Port in dependency order. The critical discipline: **never port a stage without
an automated way to prove it agrees with the C one.** Ludic is unusually well
set up for this, because it already ships two canonical serializers of compiler
internals.
| Sub-stage | Port | Differential oracle |
|---|---|---|
| 2a | `lex.ludic` | Dump the token stream from both compilers; `diff` over every `.ludic` in the tree. |
| 2b | `parse.ludic` (AST) | **`--fmt` is a free oracle.** The formatter is already a canonical AST printer, and `bin/x test` already asserts formatting never changes a program. If both compilers' `--fmt` output is byte-identical on every file, the parsers agree. |
| 2c | `check.ludic` | Diagnostic text must match on a corpus of deliberately-broken programs. `bin/x test` already checks diagnostics — extend that corpus. |
| 2d | `emit.ludic` (IR) | **`--emit llvm` must be byte-identical** for every example. This is the strongest oracle available: pass/fail on exact text, no judgement. |
| 2e | `drive.ludic` | Assemble and link via `clang`; compare final binaries. |
Sub-stage 2b deserves emphasis. Most self-hosting projects have no cheap way to
prove two parsers agree. Ludic has one already built and already tested, which
removes the single largest source of silent divergence.
### Stage 3 — The fixpoint
```
stage1 = C-ludicc compiles ludicc.ludic -> binary A
stage2 = A compiles ludicc.ludic -> binary B
stage3 = B compiles ludicc.ludic -> binary C
assert B == C byte-for-byte <- THE bootstrap test
```
`A != B` is expected and correct: `A` was built by a different compiler, so its
codegen differs. `B == C` is the real property — a compiler that reproduces
itself has no dependency on how it was built. Also assert that `A`, `B` and `C`
all emit identical IR for every example.
If `B != C`, the cause is almost always nondeterminism in the compiler itself:
hash-table iteration order, an address baked into output, uninitialised memory.
Those are worth hunting rather than working around.
### Stage 4 — Retire the C
Once the fixpoint holds, the C compiler becomes a seed. Options:
| Option | Trade-off |
|---|---|
| **Commit the generated `ludicc.ll`** ✅ recommended | Auditable text, diffable in review, builds with `clang` alone — already a dependency. Large but honest. |
| Commit prebuilt binaries per platform | Smallest process, worst auditability; a binary blob nobody can read. What Rust does. |
| Keep `ludicc.c` forever as the seed | Zero risk, but §1's bar is never met — the C never leaves. What Go did for years. |
Recommend the IR seed: it is the only option that both removes the C and leaves
a reviewer something to read.
`tools/ludic-tools/` (3,260 lines of C: `ludic-fmt`, `ludic-lsp`) is a separate
port and should follow, not lead — once the Ludic compiler exists, both tools
should be thin front-ends over its lexer and parser instead of maintaining a
second copy of the vocabulary.
---
## 7. Toolchain independence — and where to stop
`driver.c` shells out to `clang` (overridable via `$LUDIC_CC`) to assemble IR
into an object and to link, plus `wasm-ld` for wasm. Three levels of removing
that, and only one is worth doing:
**Level 1 — self-hosted compiler, hosted toolchain. ← the goal.**
`ludicc` is Ludic; `clang` remains the IR assembler and linker driver. This is
exactly where Rust and Swift stand. Achieved at the end of Stage 4.
**Level 2 — own object writer.** Emit Mach-O / ELF / COFF directly, replacing
IR-text + `clang -c`. Requires instruction selection, register allocation and
relocations: realistically 5,000–15,000 lines of Ludic, and it *loses the LLVM
optimizer* — the generated code gets slower, which for a game language is a
real regression, not a neutral trade. **Not recommended.**
**Level 3 — own linker.** Platform-specific, deep, and buys nothing a user can
perceive. **No.**
**7.4 — The hand-written IR.** `cocoa.ll` (327 lines) and `wasm.ll` are LLVM IR,
not Ludic. Two defensible positions: keep them as *platform glue written in the
platform's own assembly language* (precisely how Rust uses `asm!` shims and how
every libc has hand-written syscall stubs), or move them into `.ludic` — which
needs **B1 function pointers**, because the `NSView` subclass installs a
function as an Objective-C IMP. Keeping them is the honest default; the README's
existing framing ("the same floor Rust and Swift stand on") already covers it.
---
## 8. Risks and gotchas
- **Do not build the AST out of ECS entities.** `LUDIC_MAX_ENT` is 1024
(`native.c:18`) and property storage is fixed arrays. It compiles, it looks
elegant, and it caps the compiler at 1024 nodes. Use `struct` (A2).
- **Do not inherit the C compiler's fixed caps.** `ludicc.c:22` has
`g_srcpath[128]`; `native.c:161` has `Val a[8]`. The Ludic port should grow
its tables (A5) rather than reproduce the limits.
- **Leak on purpose.** A compiler runs once and exits. A bump arena
(`arena.ludic`) that never frees is faster and simpler than tracked
ownership, and it sidesteps having no GC. Free at process exit — i.e. never.
- **Determinism is a feature now.** Anything order-dependent — hash iteration,
pointer values in output, uninitialised reads — breaks `B == C` in Stage 3.
Iterate tables in insertion order, not bucket order.
- **Error handling has no exceptions.** Mirror the C `die()`: write the
diagnostic to stderr (A6), then `os_exit(1)`.
- **The `str + str` mismatch (B2)** is a live example of the front-end accepting
what the backend cannot lower. Worth auditing for siblings before trusting
the typechecker as a Stage 2c oracle.
- **Size-taking intrinsics must use `ll_size_t()` / `ll_widen()`.** `size_t` is
`i32` on wasm32. Any new intrinsic with a size argument (A5) that hardcodes
`i64` will break the wasm target at link time.
- **Recursion depth is fine** — 5,000 frames verified, well past what a
recursive-descent parser needs on real source.
---
## 9. Effort
| Stage | Work | State |
|---|---|---|
| 0 | Tier A language features (argv, struct, slices, break/continue, mem_realloc, stderr) | ✅ done |
| 0.5 | `and`/`or`/`not` + short-circuit; doc checking (S9) | ✅ done (S1/S2/S4/S5/S6/S7 deferred — full-language polish) |
| 1 | `str`, `buf`, `io` support libraries in Ludic | ✅ done (`selfhost/`) |
| 2 | lexer + parser + AST + IR emitter, in Ludic | ✅ done (`selfhost/`, ~1,300 lines) |
| 3 | the fixpoint (`gen2.ll == gen3.ll`) + harness | ✅ done (`bin/x bootstrap`) |
| 4 | port the game backend, retire `ludicc.c` | ⛔ out of scope — mechanical continuation |
The self-host compiler is **~1,300 lines of Ludic** covering the compiler-subset.
A `main`-tool entry point and short-circuit `and`/`or` were the two language
additions that made it self-compilable; the rest of Stage 0 was already in place.
Roughly **6,000 lines of Ludic and 1,100 lines of C** to reach Level 1 — larger
than the 2,508-line C compiler it replaces, which is normal: the C version leans
on libc for everything in Stage 1.
**Two independent critical paths, and they can run in parallel.** Stage 0 + Stage 0.5
are C work on the existing compiler; Stage 1 is Ludic work that needs almost none of
it. The only hard barrier is that Stage 2 starts after *both*.
**The critical path is short.** A1 (argv, ~30 lines of C) plus A2/A3
(`struct` + arrays, ~400) plus A4 (`break`, ~40) is nearly all the *design* risk
in the project. Everything after it is typing against oracles that already
exist.
---
## Appendix — probe programs
Each was compiled with `bin/x app probe.ludic --headless` and run against the
current tree (`bin/x test` = 64/64).
**Recursion** ✅ → `55`
```ludic
program P {
function fib(n: int) -> int { if n < 2 { return n }; return fib(n-1) + fib(n-2) }
handler B phase Start { print_int(fib(10)); quit() }
}
```
**String comparison, hand-written** ✅ → `1`
```ludic
function streq(a: pointer, b: pointer) -> bool {
let i = 0
while true {
let ca = peek8(a,i)
let cb = peek8(b,i)
if ca != cb { return false }
if ca == 0 { return true }
i = i + 1
}
return false
}
```
**Integer → string, hand-written** ✅ → `48291`
```ludic
function itoa(v: int, buf: pointer) -> int {
let n = 0
let x = v
if x == 0 { poke8(buf,0,48); return 1 }
let tmp = mem_alloc(16)
while x > 0 { poke8(tmp, n, 48 + x % 10); x = x / 10; n = n + 1 }
let i = 0
while i < n { poke8(buf, i, peek8(tmp, n-1-i)); i = i + 1 }
mem_free(tmp)
return n
}
```
**Read a whole file** ✅ → the compiler's front door
```ludic
let f = file_open("/tmp/x.txt", "rb")
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
let b = mem_alloc(n+1)
file_read(f, b, n)
poke8(b, n, 0)
file_close(f)
```
### Syntax audit — every one of these compiles today
Each is a spelling the language accepts; the point is that the *alternative*
spelling is equally legal (§5.3).
**R1 — statements now require a separator (Rule B, syntax-redesign Phase 2)** → parse error
```ludic
# doc-check: skip — intentionally rejected under Rule B: needs a newline or ';'
program P { handler B phase Start { let x = 1 x = x + 1 print_int(x) quit() } }
```
Statements no longer sit adjacent with only spaces between them; the compiler
reports `expected newline or ';' between statements`. Put each on its own line,
or separate them with `;` (both lex to the same separator token):
```ludic
program P { handler B phase Start { let x = 1; x = x + 1; print_int(x); quit() } }
```
**R2 — commas omitted throughout** → `7`
```ludic
# doc-check: skip — composite: declaration plus statements
property Pos { x: int = 0 y: int = 0 }
spawn Hero { Pos { x: 7 y: 2 } }
```
**R3 — RESOLVED.** Every symbol form is now rejected where it is written:
```
ludicc: error: line 1: '&&' is not a Ludic operator - write 'and' (got '&&')
ludicc: error: line 1: '||' is not a Ludic operator - write 'or' (got '||')
ludicc: error: line 1: '!' is not a Ludic operator - write 'not' (got '!')
ludicc: error: line 1: 'and' is a reserved operator and cannot be used as a name
```
`--fmt` prints `if ((true and false) or (1 < 2))` and `(not true)`, while unary
minus keeps its tight spelling `(-x)`. `!=` is untouched.
**R5 — reserved-looking words used as locals** → `11`
```ludic
let query = 5
let phase = 6
print_int(query + phase)
```
**R6 — contracts accepted and discarded.** Both are violated; it compiles and
prints `4`:
```ludic
# doc-check: skip — composite: declaration plus statements
function half(n: int) -> int requires n > 100000 ensures false { return n / 2 }
print_int(half(8))
```
And a system may declare read-only access, then write — also compiles:
```ludic
handler Violate phase Update reads [Pos] query (p) [Pos] { p.x = 99 }
```
**R8 — the two formatters disagree about what "format" means.** Given
`property Pos { x: int = 0 y: int = 0 }` and a multi-statement one-liner,
`ludicc --fmt` rewrites both (one statement per line, `and`→`&&`, full
parenthesisation) while `ludic-fmt` returns the input **unchanged**.
**Verified-missing** — each a compile error today:
```ludic
# doc-check: expect-error — every line here is a compile error by design
while i < 10 { i = i + 1; if i == 3 { break } } # unknown identifier 'break'
struct Node { k: int, a: pointer } # expected declaration (got 'struct')
var t: [int; 8] # expected identifier (got '[')
var h: fn = a # unknown type 'fn' for var h
let p = &cb # fails to compile
print_int(os_argc()) # unknown function 'os_argc'
print_err("x") # unknown function 'print_err'
let p = mem_realloc(ptr_null(), 10) # unknown function 'mem_realloc'
print_str("ab" + "cd") # passes front-end, invalid IR
let a = 100000; print_int(a*100000) # 1410065408 — i32 wrap
```

View file

@ -10,7 +10,7 @@
> convenience wrapper over the compiler. `--fmt` is reimplemented as a lex+parse
> gate (the doc-check hook). The `--target`/cross-compile and `--shared` paths are
> still features of the old C driver not yet re-implemented on the self-hosted
> toolchain. See BOOTSTRAP.md §5.7.
> toolchain. See the [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §5.7 on the wiki.
>
> From a clean checkout, build the compiler and the task-runner in one line, then
> let `bin/x` do the rest (run it from the repository root):

View file

@ -1,670 +0,0 @@
# Events & modding, expanded — a design doc
> **Status: EV0 fully shipped; EV1 (spawn/despawn), EV2 (first cut) and EV3
> shipped; EV4–EV7 are design.** Implemented, self-hosted to the C-free fixpoint,
> and each a `bin/x test` check:
> - **EV0** — `event`/`@On`/`emit` lowered to `@ev_<E>` dispatch (compile-time
> listeners), **plus the foreign C ABI** (`ludic_on_<E>`, the `%Ev_<E>` payload
> struct, a fixed-capacity listener array), proven by a C mod in
> [`tests/mod_c/mod.c`](tests/mod_c/mod.c) binding
> [`examples/mod_host.ludic`](examples/mod_host.ludic). Byte-identical when no
> event is declared. ([`examples/events/events.ludic`](examples/events/events.ludic))
> - **EV1** — public events across the **whole architecture**, every scope shipped:
> **program** (`@Public @OnStart`/`@OnQuit` → `program_start`/`program_quit`,
> [`examples/events/program_events.ludic`](examples/events/program_events.ludic)); **models**
> (`@Public @OnSpawn`/`@OnDespawn` → `model_<M>_spawn`/`_despawn`,
> [`examples/events/promote.ludic`](examples/events/promote.ludic)); **properties** (`@Public
> @OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_<P>_attach` etc.,
> [`examples/events/prop_events.ludic`](examples/events/prop_events.ludic)); **scenes** (a
> `public` scene → `scene_<S>_enter`/`_exit`,
> [`examples/events/scene_events.ludic`](examples/events/scene_events.ludic)); and **layers** (a
> `public` layer + `enable/disable layer L` → `layer_<L>_show`/`_hide`,
> [`examples/events/layer_events.ludic`](examples/events/layer_events.ludic)) — which also
> landed **SCENES E2 layer toggle** (`@LE_<L>` flag gating a layer's handlers).
> - **EV2 / EV2b** — the world table: the reflection ABI, generated from the
> compile-time schema, so a mod reads, writes, scans, identifies, **and creates**
> entity state **by name** without compiling against the game. `ludic_prop_id` /
> `ludic_field_id` / `ludic_get` / `ludic_set` / `ludic_has` (read/write —
> [`world_mod.c`](tests/mod_c/world_mod.c)); `ludic_entity_count` / `ludic_kind` /
> `ludic_model_id` (scan and identify — [`world_scan.c`](tests/mod_c/world_scan.c));
> `ludic_spawn(model_id)` (create, reusing the compiler's own spawn lowering —
> [`world_spawn.c`](tests/mod_c/world_spawn.c)); `get`/`set` address each field by
> its real struct offset, correct for `int`/`fixed`/`byte`/`ptr` and mixed layouts
> ([`world_mixed.c`](tests/mod_c/world_mixed.c)); and iterate
> (`ludic_query_next`, [`world_query.c`](tests/mod_c/world_query.c)). Emitted only
> for an ECS program that declares events, so event-free games stay byte-exact.
> The world table is complete: read, write, scan, identify, create, iterate.
> - **EV3** — `cancellable` events, the `cancel` verb, and `emit E(…)` as an
> expression returning the veto flag. ([`examples/events/cancel.ludic`](examples/events/cancel.ludic))
> - **EV5** — leak-proof scoped listeners: `ludic_off_<E>(token)` (explicit
> unregister; dispatch skips tombstoned slots), `ludic_on_entity_<E>(entity, cb)`
> (entity-scoped), and a generated `ludic_sweep_entity` called from `despawn` that
> nulls every listener the dying entity owned — a listener can't leak past its
> entity. Proven by [`tests/mod_c/scoped_mod.c`](tests/mod_c/scoped_mod.c).
> - **EV6** — re-entrant `emit` is depth-bounded (`@ev_depth` vs `EV_DEPTH_CAP`): a
> listener may emit another event, but an event cycle traps as an early return
> instead of hanging the frame. Dispatch order was already deterministic (array,
> registration order). Proven by [`examples/events/recurse.ludic`](examples/events/recurse.ludic).
>
> - **EV7 (schema opening)** — a mod defines a brand-new component at runtime:
> `ludic_register_prop(name, nfields)` mallocs flat `[MAX_ENT × nfields × i32]`
> storage + a has-flag array and returns a prop id past the compile-time range;
> `ludic_attach_dyn`/`ludic_detach_dyn` toggle it on an entity; `get`/`set`/`has`/
> `prop_id` fall through to the dynamic registry for ids ≥ the compile-time count.
> A mod adds entirely new data to entities by name, with per-entity isolation.
> Proven by [`tests/mod_c/world_dyn.c`](tests/mod_c/world_dyn.c). (EV7's other
> half — networking's local/remote event split — has no substrate in Ludic yet.)
>
> Still design: EV4 (the scripting-shim bridge — deferred to keep the suite
> interpreter-free) and EV7 networking. This is a companion to
> [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) and [SCENES-DESIGN.md](SCENES-DESIGN.md).
> Where those docs extend Ludic's *internal, compile-time* lifecycle, this one
> proposes the *external, runtime* layer that turns those same lifecycle moments
> into a public event surface — the foundation a game can hand to mods written in
> Ludic, JS/TS, Lua, or anything with a C ABI. It distills a survey of modding and
> event systems (§3) into a phased roadmap (EV0–EV7, §12–§13). §14 lists the open
> decisions.
---
## 1. Thesis
Ludic already has a lifecycle. `@OnSpawn(Enemy)`, `@OnDetach(Sprite)`, scene
`on enter`, `@OnDespawn(M, reason: r)` — every one is a **compile-time,
closed-world, zero-cost** hook that desugars to a direct call at a fixed site.
That is the right design for the *game author*, who is compiled together with the
game. It is exactly the wrong design for a *mod author*, who is not.
A modding event system is the mirror image of the lifecycle layer along three axes:
| | Lifecycle hooks (today) | Modding events (this doc) |
|---|---|---|
| World | **closed** — all handlers known at compile time | **open** — mods add listeners after compilation |
| Binding | **static** — a checked symbol, a direct call | **dynamic** — registered at load, dispatched at runtime |
| Language | **in-language** — Ludic, compiled together | **cross-language** — JS/TS/Lua/native over an ABI |
The instinct would be to build a second, parallel system. **The design that keeps
Ludic's discipline builds one system seen from two sides.** A lifecycle hook is a
*private* view of a moment; a public event is the *same moment* exposed across the
ABI. The author promotes a hook to an event; the compiler keeps its zero-cost
direct calls **and** emits one guarded `bus_emit` at the very same site. Nothing
exposed → nothing emitted → goldens stay byte-identical, exactly like `has_ecs`
and the `g_ondespawn` shutdown walk.
**The Luanti dividend.** The gap analysis (`LUANTI-ROADMAP.md`) found that ~57k of
Luanti's lines exist only to bridge C++ and Lua, and that its mod predicates are
*runtime strings* it must re-interpret every call. Ludic pays neither tax. The
reflection surface a mod needs — "what properties exist, what fields, at what
offsets" — is a **compile-time fact**; the compiler can *generate* the bridge
instead of a human hand-writing 57k lines, and it is always in sync with the game
it describes. A mod itself written in Ludic and compiled to a shared library binds
that surface with **zero marshalling**; a Lua mod binds the same surface through
its FFI. One ABI, every language.
---
## 2. What Ludic has today, and why it can't reach a mod
The lifecycle table from [LIFECYCLE-DESIGN.md §2](LIFECYCLE-DESIGN.md), every cell
filled, every cell a zero-cost desugar:
| Scope | Setup hook | Teardown hook | Fire site the compiler already owns |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / shutdown |
| entity | `@OnSpawn(M)` | `@OnDespawn(M, reason)` | `spawn` / `despawn` / shutdown-walk |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` (and `push`/`pop`, SCENES E3) |
Two more fire sites are proposed but unbuilt, and both are natural events:
`@OnChange(P)` (LC2 — a value-change hook the compiler can emit right after every
write site) and `@OnStartMatch`/`@OnStopMatch` (LC3 — query-membership edges).
Every one of these is a place the compiler **already writes a call**. The problem
is purely that the call is *closed*: its targets are fixed at compile time, so a
mod loaded at runtime has no way to be one of them. The entire job of this doc is
to add, at each of these sites, an **opt-in second exit** to an open runtime list —
without touching the closed path's cost when no one opts in.
What a mod additionally needs, that no hook provides:
- a **stable name** for each event that survives recompilation (a mod compiled
against v1 must still bind in v1.1);
- a way to **read and write game state** it did not compile against (the world
table, §9);
- a way to **veto or rewrite** an action before it commits, not just observe it
after (cancellable events, §8);
- a **loader** — mods enable, disable, and unload, and their listeners must vanish
cleanly when they do (§10).
---
## 3. Research digest — the one idea to steal from each
The lifecycle doc surveyed engines for *internal* lifecycle. This surveys systems
for their *modding and event* surface — how untrusted, separately-authored code
plugs into a running game.
| System | The transferable idea |
|---|---|
| **Bukkit / Spigot** (Minecraft) | The canonical **cancellable event**: `Cancellable.setCancelled(true)` vetoes the action; `EventPriority` orders listeners; `@EventHandler(ignoreCancelled=true)` opts out of already-vetoed events. Events are *classes*, checked at bind time — not strings. |
| **Fabric** (Minecraft) | `Event<T>` backed by an **invoker over a plain array** of callbacks — deterministic registration order, no reflection at dispatch, phases for ordering. The closest existing design to what Ludic wants: fast, ordered, array-backed. |
| **Factorio** | **Deterministic** modded events for multiplayer lockstep: `script.on_event(defines.events.X)`, numeric event ids, `raise_event` for custom events, **filtered** subscriptions. Proof that a heavily-modded game can still replay bit-for-bit. |
| **Minetest / Luanti** | `register_on_*` + a string-keyed global (`minetest.*`) world API. The thing to beat: its predicates are runtime strings, and its C++↔Lua bridge is 57k hand-written lines. |
| **Godot** | **Signals as a first-class language construct**: `signal hurt(amount)`, `emit_signal`, `connect`. Decoupled, per-object, declared where the data lives. |
| **DOM events** | The **two-phase dispatch** vocabulary: capture → target → bubble, `preventDefault` (veto the default action) vs `stopPropagation` (halt the chain), and *passive* listeners that promise not to cancel (so dispatch can skip the veto check). |
| **Node `EventEmitter`** | The dead-simple baseline `on`/`emit` — and its footguns: untyped string names (a typo silently never fires) and **listener leaks** (a listener on a dead object keeps it alive). Design both out. |
| **flecs / Bevy observers** | **ECS-native reactive events**: an event *targeted at an entity*, observers that fire on component add/set/remove, deferred so mutation-during-iteration is safe. The correct shape for an ECS. |
| **Blender `bpy.app.handlers`** | Named application-level handler lists a script appends to, with a `persistent` flag controlling survival across file loads — the "engine lifecycle exposed to scripts" model, and the lesson that *survival scope* must be explicit. |
| **Roblox** | `BindableEvent` (local) vs `RemoteEvent` (across the network boundary) — the same event abstraction, one flag deciding whether it crosses a trust/latency boundary. Relevant the day Ludic has networking. |
Five **footguns** the survey warns against, to design *out* of Ludic from the start:
1. **Untyped string events.** Node/DOM let any string be an event; a typo never
fires and never errors. Ludic's core events are compiler-checked symbols; only
genuinely-dynamic *mod-defined* events use interned strings, and those must be
*registered* before use (§6), so an unknown name is a load-time error, not a
silent no-op.
2. **Listener leaks.** A listener bound to an entity that despawns must die with
it. Ludic ties listener lifetime to the scope it names (§10) — entity-scoped
listeners are swept by the same despawn walk that already runs.
3. **Nondeterministic dispatch order.** Hash-map iteration over listeners breaks
replay and save-load. Ludic dispatches in a **defined order** (priority, then
registration order) so a modded game stays deterministic — a hard constraint,
not a nicety, given Ludic's deterministic-by-design rng and byte-identical
goldens.
4. **Re-entrancy / mutate-during-dispatch.** A listener that emits another event,
or despawns the entity mid-dispatch, is the flecs "command during iteration"
hazard. Ludic defers structural changes made inside dispatch to the next sync
point (ties to LIFECYCLE LC5), and bounds re-entrant emit depth.
5. **Cancellation ambiguity.** If two listeners disagree, who wins? Ludic's rule
(§8): **one veto wins and is sticky**; later listeners see the cancelled state
and, unless they opted into `ignoreCancelled`, are skipped.
---
## 4. The two layers, named
To talk about this precisely the doc fixes two words:
- A **hook** is the existing compile-time construct: an `@`-annotation or scene
clause that desugars to a direct call. Closed, zero-cost, author-only. Unchanged.
- An **event** is the new runtime construct: a named, ABI-visible moment that any
registered listener — in any language — may observe or (if cancellable) veto.
An event is *fed by* a hook site. Promoting is additive: the hook keeps firing its
compile-time listeners as direct calls; the event is an extra, guarded emission at
the same site. **Author code never pays for the bus it doesn't expose, and mod
code never sees a hook it wasn't given.**
---
## 5. EV0 — the event bus core
The minimum viable layer: declare an event, emit it, and have both in-language and
foreign listeners receive it — with zero cost when a program declares no events.
**Declaring a custom event.** A first-class declaration, mirroring `property`:
```ludic
# doc-check: skip — sketch
event PlayerHurt { entity: int, amount: int } # a payload is a flat POD record
event WaveCleared { } # payloads may be empty
```
**Emitting.** A statement, mirroring `spawn`/`emit_signal`:
```ludic
# doc-check: skip — sketch
emit PlayerHurt(entity: e, amount: dmg)
```
**Listening in-language** (author code, or a *native* Ludic mod) reuses the
annotation channel, mirroring `@OnSpawn`:
```ludic
# doc-check: skip — sketch
@On(PlayerHurt) handler FlashRed { hud_flash(0xFF0000) }
```
**Listening across the ABI** (a JS/TS/Lua mod) goes through the stable C ABI:
```c
/* the entire foreign-facing event ABI — four functions */
uint32_t ludic_event_id(const char *name); /* intern → stable id */
uint32_t ludic_on(uint32_t event, int32_t prio, ludic_cb cb, void *ctx);
void ludic_off(uint32_t token);
void ludic_emit(uint32_t event, void *payload); /* mod-raised events */
/* cb: void (*)(void *ctx, void *payload) — payload is the flat POD record */
```
**Lowering — the discipline holds.** An exposed event's emit site becomes:
```
; emit PlayerHurt(entity: e, amount: dmg) lowers to:
1. build the payload record on the stack (POD, no heap)
2. call each compile-time @On(PlayerHurt) handler directly ; zero-cost path
3. if g_listeners[EV_PlayerHurt].count != 0: ; one branch
loop the runtime listener list, calling each cb(ctx, &payload)
```
- **A program that declares no `event` emits none of this.** A `has_events` flag
(exactly like `has_ecs`, `g_ondespawn`) gates the whole subsystem; a game with no
public events is byte-for-byte identical to today. This is the non-negotiable
invariant every phase preserves.
- The compile-time `@On` handlers are direct calls appended to the site — a native
listener costs the same as a lifecycle hook. Only *foreign* listeners walk the
runtime list, and an event with zero foreign listeners is a single count check.
- The runtime list is a **compiler-owned, fixed-capacity buffer** per event
(like the scene stack in SCENES E3) — not heap, not a hash map. `ludic_on` is an
index bump; `ludic_off` tombstones a slot. Deterministic order falls out of the
array (§7 of SCENES' "no dispatch tables" spirit, honestly bent — see §11).
---
## 6. EV1 — promoting hooks to events (the taxonomy)
Custom `event`s (EV0) cover author-raised signals. The **lifecycle** events —
spawn, despawn, attach, scene enter — should not require the author to hand-write
an `emit` in every `@OnSpawn`. Instead, a hook is promoted with one annotation:
```ludic
# doc-check: skip — sketch
@Public @OnSpawn(Enemy) handler Init { Health.hp = Health.max }
# now firing this hook ALSO emits the public event model.Enemy.spawn
```
`@Public` on a lifecycle hook tells the compiler to add the guarded `bus_emit` at
that hook's existing site, with a **generated payload** built from what the hook
already binds (the entity id, the model/property fields, the `EndReason`). The
result is a uniform event namespace across the whole architecture — precisely the
"events for properties, models, scenes, layers, game" the request asks for:
| Scope | Public event name | Payload | Fed by |
|---|---|---|---|
| program | `program.start` / `program.quit` | `{}` | `@OnStart` / `@OnQuit` |
| phase | `phase.<Name>.pre` / `.post` | `{ frame }` | the phase scheduler |
| model | `model.<M>.spawn` / `.despawn` | `{ entity, reason? }` | `@OnSpawn` / `@OnDespawn` |
| property (structural) | `prop.<P>.attach` / `.detach` | `{ entity, <fields> }` | `@OnAttach` / `@OnDetach` |
| property (toggle) | `prop.<P>.enable` / `.disable` | `{ entity }` | `@OnEnable` / `@OnDisable` |
| property (value) | `prop.<P>.change` | `{ entity, field, old, new }` | `@OnChange` (LC2) |
| query (membership) | `query.<Q>.enter` / `.exit` | `{ entity }` | `@OnStartMatch`/`@OnStopMatch` (LC3) |
| scene | `scene.<S>.enter` / `.exit` / `.push` / `.pop` | `{}` | `on enter`/`on exit`, `push`/`pop` |
| layer | `layer.<L>.show` / `.hide` | `{}` | layer toggle (SCENES E2) |
- **Names are stable strings, ids are fast integers.** `model.Enemy.spawn` is the
public contract; the compiler assigns it a numeric id and registers the mapping
in a generated init. A mod compiled against the string binds by id at load — so
reordering declarations doesn't break a shipped mod (unlike raw
decl-order numbering, which is fine for the *closed* scene machine but wrong for
an *open* ABI).
- **Opt-in per hook, not global.** Only `@Public` hooks emit. A game exposes the
slice of its lifecycle it wants moddable and pays for nothing else.
- **`@Public` composes with everything.** A `@Public @OnDespawn(Enemy, reason: r)`
emits `model.Enemy.despawn` with the `EndReason` in the payload — mods can tell a
scene-exit death from a real one, the LC1 dividend extended to the mod boundary.
---
## 7. EV2 — the world table (reflection for mods)
The user's "game table": the stable, versioned surface a mod uses to **read and
write game state it never compiled against**. Minetest's `minetest.*`, Factorio's
`game.*`, but *generated* rather than hand-written.
Because Ludic's data is packed POD in `@S_` arrays whose layout the compiler knows
exactly, the compiler can emit a **schema** (property id → field ids → offset +
type) plus a small accessor ABI over it:
```c
/* the world table — reflection + mutation over the live ECS */
uint32_t ludic_prop_id(const char *name); /* "Health" → id */
uint32_t ludic_field_id(uint32_t prop, const char *name); /* ("Health","hp")→id */
int64_t ludic_get(int32_t entity, uint32_t prop, uint32_t field);
void ludic_set(int32_t entity, uint32_t prop, uint32_t field, int64_t v);
bool ludic_has(int32_t entity, uint32_t prop);
int32_t ludic_spawn(uint32_t model); /* → entity */
void ludic_despawn(int32_t entity);
uint32_t ludic_query(uint32_t *props, int n); /* → iterator handle */
int32_t ludic_query_next(uint32_t iter); /* → entity or -1 */
```
- **Generated from the compile-time schema, so it never drifts.** Add a field to
`Health`, recompile, and the schema updates; a mod that asked for
`("Health","hp")` still resolves. This is the entire Luanti bridge, minus the
hand-written 57k lines and minus the runtime-string re-interpretation.
- **`ludic_set` respects the semantic layer.** Writing a field routes through the
same path a native write does, so `@OnChange`/`prop.change` (LC2) fires for a
mod's write exactly as for the author's — mods can't silently corrupt invariants
that hooks are meant to maintain.
- **Mods can register content, within limits.** A mod may `ludic_on` existing
events and `ludic_emit` custom ones; **defining a new `property`/`model` is a
harder call** (it needs storage the closed `@S_` arrays didn't reserve). The
pragmatic first cut: models and properties are closed (author-defined), and mods
extend *behavior* (listeners, custom events, world reads/writes) but not the
*schema*. Opening the schema to mods is EV-late (§13, open decision 4).
---
## 8. EV3 — cancellable and mutable events
Observation alone (Node, Blender) can't stop a mod from turning damage off — the
modding headline is that a listener runs **before** the action and can veto or
rewrite it. Events split into two kinds, distinguished at declaration:
- **notifications** — fired *after* the fact, observe-only, can't change anything.
Cheap, un-ordered-safe, the default. `model.Enemy.spawn` after the spawn.
- **decisions** — fired *before* the action, listeners may **cancel** it or
**mutate** the payload; the caller reads the verdict and branches. Marked
`cancellable` (Bukkit `Cancellable`, DOM `preventDefault`).
```ludic
# doc-check: skip — sketch
event cancellable BeforeHurt { entity: int, amount: int } # a decision event
# an author (or native mod) listener that halves fire damage and vetoes lethal hits:
@On(BeforeHurt, prio: 100) handler Armor {
BeforeHurt.amount = BeforeHurt.amount / 2 # mutate the payload…
if BeforeHurt.amount >= Health.hp { cancel } # …or veto the whole action
}
# the fire site consults the verdict:
let dmg = emit? BeforeHurt(entity: e, amount: raw) # emit? returns the (maybe-mutated) payload
if !cancelled(dmg) { Health.hp -= dmg.amount }
```
Rules, chosen from the survey to remove the ambiguity footgun:
- **Priority, then registration order.** `prio:` (default 0) orders listeners
high-to-low; ties break by registration order. Deterministic, replay-safe.
- **One veto wins and is sticky.** Once a listener calls `cancel`, the event is
cancelled for the rest of the chain; later listeners still run (so they can react
to the cancellation) unless declared `ignoreCancelled`, which skips them.
- **`stopPropagation` is separate from `cancel`.** DOM's distinction: `cancel`
vetoes the *action*, `halt` stops the *chain*. Keep both; they answer different
questions.
- **Passive listeners.** A listener declared `@On(E, passive)` promises not to
cancel or mutate — the dispatcher can call it after the decision is settled, and
a foreign listener that lies is a load-time capability error (§10), not a
mid-frame surprise.
- **Mutation is bounded to the payload.** A decision listener rewrites *the payload
record*, never arbitrary world state, so the caller's branch is the only place
the change takes effect — no spooky action at a distance.
---
## 9. EV4 — the mod ABI & the language-agnostic bridge
"Agnostic JS/TS/Lua or their own" resolves cleanly once EV0–EV3 exist, because the
contract is **the C ABI, not any one language.** Two mod tiers bind the *same* four
event functions (§5) and the same world table (§7):
**Tier 1 — native mods (Ludic → shared library).** A mod is a `.ludic` file
compiled to a `.dylib`/`.so`/`.wasm` with `extern fn` bindings
([LANGUAGE.md §Functions & FFI](LANGUAGE.md)). It binds the ABI with **zero
marshalling** — payloads are the same POD records the host builds — and its `@On`
handlers can even be *inlined by the same compiler* if the mod is compiled with the
game. This is the tier Luanti can't offer and the one that makes Ludic's modding
fast: a compiled predicate where Luanti has a re-interpreted string.
**Tier 2 — scripted mods (JS/TS/Lua/…).** The game embeds a scripting runtime
(QuickJS, Lua, Wasm) and registers a thin per-language shim that:
1. calls `ludic_event_id("model.Enemy.spawn")` once at load to resolve the id;
2. calls `ludic_on(id, prio, trampoline, script_fn)` where `trampoline` is a
single C function that marshals the POD payload into the script runtime's values
and invokes `script_fn`;
3. exposes the world table (§7) as idiomatic bindings (`world.get(e, "Health",
"hp")` in Lua, `world.get(e, "Health", "hp")` in TS).
The host writes **one trampoline per language**, not one per event — the schema
(§7) drives the marshalling generically. A Lua mod and a TS mod differ only in
their shim; the game core is identical. This is the structural win the Luanti gap
analysis pointed at: the bridge cost is *O(languages)*, not *O(events × languages)*
hand-written, because the schema is generated.
```
┌─────────────── the stable C ABI ───────────────┐
Ludic game core ──────┤ ludic_on / ludic_emit / ludic_get / ludic_set ├────── generated schema
(emits at hook sites) └────────────────────┬───────────────────────────┘ (prop→field→offset)
│
┌────────────────────────────────┼────────────────────────────────┐
│ │ │
Tier 1: native mod Tier 2: Lua shim Tier 2: JS/TS shim
(.dylib, zero marshalling) (one trampoline) (one trampoline)
```
---
## 10. EV5 — mod lifecycle, scoping & leak-proofing
A mod is not eternal; it loads, enables, disables, and unloads, and its listeners
must vanish with it — the Node listener-leak footgun, solved structurally.
- **Every registration returns a token** (`ludic_on → token`), and a mod's tokens
are tracked under its **mod handle**. Unloading a mod calls `ludic_off` on all of
them at once — a mod can't leak a listener past its own life.
- **Listeners may be scoped to a game object.** `ludic_on_entity(entity, …)` binds
a listener that the **existing despawn walk** sweeps when that entity dies — the
same `@L_despawn_all` loop LC1 already emits, extended to drop entity-scoped
listeners. An entity-scoped listener on a dead entity is impossible by
construction, not by discipline.
- **Scene-scoped listeners** ride SCENES E1: a listener registered while a scene is
active is dropped by that scene's synthesized `on exit`, alongside its owned
entities. Overlay push/pop (SCENES E3) scopes listeners to the overlay's life.
- **Survival is explicit** (Blender's `persistent` lesson): a listener is
program-, mod-, scene-, or entity-scoped, chosen at registration. There is no
implicit "lives forever" — the default is the narrowest scope that makes sense
(mod), and wider survival is opt-in and visible.
- **Capabilities gate what a scripted mod may touch** (§14, open decision 6). A mod
manifest declares the events and world-table properties it needs; the loader
grants ids only for those. A mod that never asked for `Health` cannot `ludic_set`
it — an untrusted-code boundary the closed lifecycle layer never needed but an
open mod ABI must have.
---
## 11. EV6 — determinism, re-entrancy & the one honest compromise
Ludic is deterministic by design — deterministic rng, byte-identical PPM goldens,
save-load of the whole World. A modding layer is the classic place that determinism
goes to die (hash-ordered listeners, mods reading wall-clock, emit storms). Holding
the line is a **feature**, and the same one that makes Factorio's modded multiplayer
lockstep-correct.
- **Dispatch order is total and defined** — priority, then registration order, over
an *array*, never a hash map. Two mods loaded in the same order dispatch in the
same order on every machine.
- **Emit is synchronous by default, deferred on demand.** `emit E` runs listeners
now (push-at-the-site, Ludic's natural style — the LIFECYCLE footgun-1 fix).
Structural changes a listener requests (spawn/despawn/attach) **defer to the next
sync point** (LIFECYCLE LC5's `defer`), so mutate-during-dispatch is safe and
batched. Re-entrant `emit` inside a listener is allowed but **depth-bounded** (a
compile-time cap, trap on overflow) so an event cycle can't hang a frame.
- **Foreign listeners are the determinism boundary.** A native (Tier 1) listener is
as deterministic as any handler. A scripted (Tier 2) listener is only as
deterministic as the script — so the sandbox (§10) can **deny nondeterministic
capabilities** (wall-clock, unseeded rng, filesystem) to a mod that must stay in
a deterministic session (multiplayer, replays). Single-player mods can opt out.
**The one honest compromise.** SCENES-DESIGN's principle is "no dispatch tables —
the active-scene path is a register read and a static branch." The runtime
listener list *is* a dispatch table, walked at runtime. This doc owns that: it is
the **deliberate, opt-in exception**, justified because open-world extension is the
entire point of a mod ABI and cannot be resolved at compile time by definition.
The mitigations keep it honest — it is (a) gated behind `has_events` so unused it
costs nothing, (b) an array not a hash map so it stays deterministic, (c) fed by
compile-time-checked names so the *closed* side stays typed, and (d) reached only
after the zero-cost direct calls to compile-time `@On` handlers. Ludic pays for a
dispatch table exactly when, and only when, a game chooses to be moddable.
---
## 12. Lowering summary
Everything above reduces to constructs Ludic already has or honestly-scoped
additions to them:
| Construct | Lowers to |
|---|---|
| `event E { … }` | a generated payload record type + a reserved event id + a `has_events` bump |
| `emit E(…)` | build POD payload · direct-call each `@On(E)` handler · `if count: walk runtime list` |
| `@On(E)` handler | a compile-time listener: a direct call appended to `E`'s emit site (zero-cost) |
| `@Public @OnX(…)` | the existing hook's site, plus a guarded `bus_emit` of a payload built from the hook's bindings |
| public event name | a stable string interned to an integer id in a generated registry init |
| the runtime listener list | a compiler-owned fixed-capacity array per event; `ludic_on` = index bump, `ludic_off` = tombstone |
| the world table | a generated schema (prop→field→offset/type) + accessor ABI over the live `@S_` arrays |
| `cancellable` / `cancel` | a verdict field on the payload; the emit site branches on it |
| entity/scene-scoped listener | dropped by the existing despawn walk / synthesized `on exit` (LC1 / SCENES E1) |
| deferred structural change in a listener | LIFECYCLE LC5's `defer` queue, flushed at the sync point |
No heap for native payloads, no hash map, no per-event hand-written bridge. The
active game path is unchanged unless it opts in; the opt-in cost is one branch per
exposed event plus the listeners a mod actually registers.
---
## 13. Design principles distilled
1. **One system, two sides.** A public event is a lifecycle hook seen from across
the ABI. Don't build a parallel event runtime; promote the sites you already
have.
2. **Opt-in or invisible.** No `event`, no `@Public` → byte-identical goldens.
`has_events` gates the world the way `has_ecs` gates the ECS.
3. **Closed stays typed; only the open edge is dynamic.** Core events are
compiler-checked symbols; string names exist only at the genuinely-runtime mod
boundary, and even there must be registered (no silent typos).
4. **Generated bridge, never hand-written.** The world table and payload marshalling
come from the compile-time schema, so they never drift and cost O(languages),
not O(events × languages). This is the Luanti dividend — spend it.
5. **Deterministic dispatch is a feature.** Array order, not hash order; deny
nondeterministic capabilities to mods in deterministic sessions. Modded replay
and modded multiplayer depend on it.
6. **Lifetime follows scope, explicitly.** Every listener names its scope
(program/mod/scene/entity); the existing teardown walks sweep it. No implicit
immortality, no leaks.
7. **One ABI, every language.** The C ABI is the contract. Native mods bind it with
zero marshalling; scripted mods bind it through one trampoline per language.
Ludic never blesses a single scripting language.
8. **Only the semantic layer, still.** Mods observe and decide; they do not get
ctor/dtor/move hooks Ludic doesn't have. POD in, POD out.
---
## 14. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases
work (and how LIFECYCLE/SCENES sequence).
- **EV0 — the bus core.** ✅ *Compile-time half shipped.* `event` / `emit` / `@On`
with the `g_events`-gated zero-cost lowering: an event compiles to a `@ev_<E>`
function whose body is its listeners in declaration order (payload bound by
name as params), and `emit E(…)` is a direct call. Verified byte-identical for
event-free programs, self-hosted to the C-free fixpoint. Still open in EV0: the
foreign C ABI (`ludic_on`/`ludic_emit`) and its runtime listener array, so a
mod in another language can join the same dispatch. Implementation notes: AST
`N_EVENT`/`S_EMIT`; `parse_event` + `@On` annotation + `emit` statement (guarded
by an identifier-lookahead so a bare `emit(...)` call still parses); registries
`g_events`/`g_onlisten` (emit_core); `emit_event_fns` (emit_game); `emit_emit`
(emit_stmt). [`examples/events/events.ludic`](examples/events/events.ludic) is a `bin/x test` check.
- **EV1 — `@Public` hook promotion.** ✅ *All scopes shipped.* `@Public` on a
lifecycle hook fires a public event at that hook's site (payload: entity, plus
`EndReason` for despawn); `find_event(name)` doubles as the "is this hook
public?" gate. Covered: program (`@OnStart`/`@OnQuit` → `program_start`/`_quit`),
models (`@OnSpawn`/`@OnDespawn`), properties
(`@OnAttach`/`@OnDetach`/`@OnEnable`/`@OnDisable` → `prop_<P>_…`). Scenes and
layers use a `public` block modifier instead of an annotation:
`scene_<S>_enter`/`_exit` at the synthesized scene functions, and
`layer_<L>_show`/`_hide` at the layer-toggle site. Building layer events also
delivered **SCENES E2 layer toggle**: `enable/disable layer L` flips an `@LE_<L>`
flag that gates that layer's handlers, emitted only for toggled layers so
untouched scene programs stay byte-identical.
- **EV2 / EV2b — the world table.** ✅ *Read/write/scan/identify/create shipped.*
The generated reflection ABI (§7), dispatching a runtime prop/model id to the
right `@S_`/`@H_`/`@L_kind` storage: read/write (`prop_id`/`field_id`/`get`/`set`/
`has`), scan/identify (`entity_count`/`kind`/`model_id`), and create
(`spawn(model_id)`, which reuses the compiler's own spawn lowering — defaults,
`@OnSpawn`, and the spawn event). `get`/`set` address each field by its real
struct offset (constant struct GEP), correct for `int`/`fixed`/`byte`/`ptr`
fields and mixed layouts alike. Emitted only for an ECS program that declares
events (gated on `has_ecs() && g_events`), so event-free games are byte-identical.
A `ludic_query_next(prop, from)` cursor iterates live entities that have a
property. The world table is complete: read, write, scan, identify, create,
iterate.
- **EV3 — cancellable events.** ✅ *Shipped.* `event cancellable E`, the `cancel`
verb, and `emit E(…)` as an expression yielding the veto flag; the flag is a
trailing field of `%Ev_<E>`, so a foreign listener vetoes by setting it. Priority
ordering and `ignoreCancelled`/`halt` (§8) remain open. The modding headline —
observation becomes control.
- **EV4 — the scripting bridge.** One reference shim (Lua *or* QuickJS) over the
ABI, proving the O(languages) claim end to end.
- **EV5 — mod lifecycle & scoping.** ✅ *Shipped.* A parallel owner array `@evO_<E>`
(-1 = program-scoped, ≥0 = owning entity); `ludic_on_<E>` and
`ludic_on_entity_<E>` register with the right owner; `ludic_off_<E>(token)`
tombstones a slot to null and dispatch skips null slots; `ludic_sweep_entity`,
called from `emit_despawn` when the program has events, nulls every listener a
despawning entity owned. Scene-scoped listeners (drop on `on exit`) remain the
same shape applied at the scene teardown — a follow-on.
- **EV6 — determinism & re-entrancy.** ✅ *Depth bound shipped.* `@ev_depth`
increments on each `@ev_<E>` entry and decrements on exit; past `EV_DEPTH_CAP`
(32) a dispatch returns immediately (a cancellable event returns "not
cancelled"), so an event cycle can't hang. Dispatch order was already
deterministic (array, registration order). Still design: deferred structural
changes at a sync point (LC5) and capability gating for deterministic sessions.
- **EV7 — schema-opening & networking.** ✅ *Schema-opening shipped.* A mod defines
a new component at runtime: `ludic_register_prop(name, nfields)` allocates flat
`[MAX_ENT × nfields × i32]` storage + a has-flag array (capacity 32 dynamic
components) and returns a prop id past the compile-time range;
`ludic_attach_dyn`/`ludic_detach_dyn` toggle presence; `get`/`set`/`has`/`prop_id`
fall through to the dynamic registry for a prop id ≥ the compile-time component
count. Per-entity storage is isolated (`world_dyn.c`). This is the first genuinely
*dynamic* `@S_` storage — a deliberate departure from the closed dense arrays, so
it lives entirely behind the ABI (the game's own components stay static and
byte-identical). Dynamic components use integer fields addressed by index (no
field-name schema). *Still design:* the local/remote event split (Roblox's
lesson) waits on Ludic having a networking substrate.
EV0–EV1 deliver "the whole architecture emits public events." EV2–EV3 are where a
mod becomes able to *change the game*. EV4 proves the language-agnostic claim.
EV5–EV7 are hardening and reach.
---
## 15. Open decisions
1. **`emit` verb & payload identity.** Is `emit E(…)` the only spelling, or does a
`signal`-style per-property declaration (Godot) read better for the common case?
Are payloads always fresh POD records, or can an emit borrow an existing property
in place (cheaper, but aliases live storage)?
2. **`@Public` granularity.** Per-hook (proposed), per-model (`@Public model
Enemy`), or a program-level "expose all lifecycle" switch for prototyping? Does
`@Public` belong on the hook or on the `model`/`property`/`scene` it concerns?
3. **Name scheme stability.** Dotted strings (`model.Enemy.spawn`) interned to ids —
confirmed. Open: are ids stable across recompiles of the *same* source (needed
for save-compatibility of a listener table), and how does a renamed model
migrate a shipped mod?
4. **Schema opening (EV2/EV7).** Do mods stay behavior-only (listeners + custom
events + world reads/writes over author-defined schema), or can a mod define new
`property`/`model`? The latter needs dynamic `@S_` storage — a real departure
from the closed dense arrays (`LUDIC_MAX_ENT 1024`). Probably EV7.
5. **Cancellation surface.** Keep `cancel` (veto action) and `halt` (stop chain)
distinct (DOM), or collapse to one? Is `ignoreCancelled` per-listener or a
priority-band convention?
6. **Sandbox model.** Capability manifest per mod (proposed) — at what granularity
(per event? per property? per world-table verb)? What is denied by default in a
deterministic session, and who declares a session deterministic?
7. **Re-entrancy bound.** Compile-time constant emit-depth cap (trap on overflow),
or a runtime budget? What is the default depth, and is an event cycle a warning
or an error?
8. **Scripting runtime, in or out of scope.** Does Ludic *ship* an embedded runtime
(QuickJS/Lua) as a blessed default, or only the ABI and reference shims, leaving
the runtime to the game? (Bias: ship the ABI + one reference shim; bless no
language.)
---
*Companion to [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (the hook sites this layer
promotes) and [SCENES-DESIGN.md](SCENES-DESIGN.md) (scene/layer/overlay events and
scoped-listener teardown). Grounded in the Luanti gap analysis (`LUANTI-ROADMAP.md`):
the generated bridge is how Ludic avoids the 57k-line C++↔Lua tax. Supersedes
nothing until the compiler work in §12 lands.*

View file

@ -396,7 +396,7 @@ scopes, [`examples/lang/detach.ludic`](examples/lang/detach.ludic) for the struc
attach/detach pair, and [`examples/lang/reason.ludic`](examples/lang/reason.ludic) for
reason-carrying teardown. The rest of the lifecycle roadmap (value-change hooks,
query-membership edges, keyed effects) is in
[LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md).
[the Lifecycle design](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Lifecycle).
**`@Handles` — the handlers a program drives.** Written in front of the
`program`, `@Handles(Move)` names the handlers it uses. It parses and reads as
@ -408,7 +408,7 @@ whole timeline), plus [`examples/lang/toggle.ludic`](examples/lang/toggle.ludic)
(enable/disable). Scenes and their `on enter` / `on exit` lifecycle blocks are
implemented — see "Scenes & layers" below. (An annotation spelling,
`@OnEnter(Scene)` / `@OnExit(Scene)`, is a designed but not-yet-built convenience
— see [SCENES-DESIGN.md](SCENES-DESIGN.md); today the hooks are written as `on
— see [the Scenes design](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Scenes); today the hooks are written as `on
enter { … }` inside the `scene`.)
## Events & modding (`event`, `emit`, `@On`)
@ -469,7 +469,7 @@ if emit BeforeHurt(amount: dmg) == 0 { hp = hp - dmg } # apply only if not vet
```
The full modding roadmap — the world-table reflection ABI, scoped/leak-proof
listeners, and the sandbox — is in [EVENTS-DESIGN.md](EVENTS-DESIGN.md).
listeners, and the sandbox — is in [the Events design](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Events).
## Records (`property`), arrays and slices
@ -821,7 +821,7 @@ are future work.
Records (`property` used with `new`) and array types, `break`/`continue`, and
argv/stderr — once listed here as near-term — are now implemented and
self-hosting; their lowerings are in
[BOOTSTRAP.md](BOOTSTRAP.md) §4.
[the Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap) §4.
## Scenes & layers
@ -831,7 +831,7 @@ self-hosting; their lowerings are in
> implicit active-scene register, states numbered by declaration order, and
> `become` as two direct calls plus a store. Richer scene features (the overlay
> stack, scene-owned entities, scene-local state, transition parameters) are
> designed in [SCENES-DESIGN.md](SCENES-DESIGN.md) and not built yet.
> designed in [the Scenes design](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Scenes) and not built yet.
A program is usually several mutually-exclusive states — a title screen, the
overworld, a battle — and the usual way to write that is a mode register

View file

@ -1,348 +0,0 @@
# Lifecycle events, expanded — a design doc
> **Status: LC0–LC1 shipped; LC2–LC6 are design.** The structural attach/detach
> pair and `@OnDetach` (§4, LC0), and reason-carrying `@OnDespawn` (§5, LC1), are
> implemented and tested ([`examples/lang/detach.ludic`](examples/lang/detach.ludic),
> [`examples/lang/reason.ludic`](examples/lang/reason.ludic), `bin/x test` checks). The
> extensions LC2–LC6 are research-informed proposals, not built. This document
> distills a survey of lifecycle models across seven systems (§3) into a roadmap
> for Ludic. §13 lists the open decisions.
---
## 1. Thesis
A game/ECS usually models lifetime as **create → destroy on a timeline**. A survey
of how other systems handle it — Unity (MonoBehaviour + DOTS), Unreal, Bevy,
flecs, EnTT, Godot, and non-game paradigms (actor model, declarative UI, RAII) —
shows that mature lifecycle designs model something richer than birth and death:
- **a reaction to a *reason*** — teardown that knows *why* it is ending (Unreal
`EndPlay(reason)`, Erlang `terminate(Reason)`, Akka `preRestart(reason, msg)`);
- **paired setup/teardown *keyed on dependencies*** — an update is teardown-then-
setup on a value change (React `useEffect`, Compose `DisposableEffect`);
- **a deterministic consequence of *scope / ownership*** — guaranteed, ordered,
single-shot teardown (C++/Rust RAII, DI scoped lifetimes);
- **an edge on *query membership*** — fire when data starts/stops matching a
composite condition (DOTS `OnStartRunning`, flecs `Monitor`).
Ludic's model is a good base: lifecycle hooks are `@`-annotations on handlers that
**desugar to ordinary code**, firing at fixed timeline moments, keeping the data
plain. This doc extends that base along the four axes above **without breaking the
desugars-to-code discipline** — every proposal lowers to plain branches and calls,
no hidden runtime.
**One structural advantage worth stating up front.** flecs and EnTT each carry
*two* lifecycle layers: a **memory** layer (ctor/dtor/move/copy — because C++
objects must be constructed and relocated as archetypes repack) and a **semantic**
layer (on_add/on_set/on_remove). Ludic's components are POD in packed `@S_`
arrays; there is nothing to construct, destruct, or move-relocate. **Ludic needs
only the semantic layer** — half the machinery, none of the "component isn't
movable" footguns. Keep it that way.
---
## 2. What Ludic has today
Seven hooks, each an annotation that desugars to a handler body at a timeline
moment ([LANGUAGE.md §Annotations](LANGUAGE.md)):
```
boot ─ @OnStart ─▶ spawn ─ @OnAttach(P), @OnSpawn(M) ─▶ … ─ @OnDetach(P)/@OnDespawn(M) ─▶ quit ─ @OnQuit
```
The lifecycle reads cleanest as a table of **paired setup/teardown** across five
scopes. Every cell is now filled — LC0 closed the one hole (`@OnDetach`):
| Scope | Setup | Teardown | Driven by |
|---|---|---|---|
| program | `@OnStart` | `@OnQuit` | boot / quit |
| entity | `@OnSpawn(M)` | `@OnDespawn(M)` | `spawn` / `despawn` |
| property (structural) | `@OnAttach(P)` | `@OnDetach(P)` ✅ | `attach` / `detach` |
| property (toggle) | `@OnEnable(P)` | `@OnDisable(P)` | `enable` / `disable` |
| scene | `on enter` | `on exit` | `become` |
Two things this table already gets right, which the survey flags as the frequent
mistakes to avoid:
- **The toggle pair is distinct from the structural pair.** Unity's clearest
lesson is separating the *repeatable* enable/disable cycle (pooling, pausing,
data kept) from the *once* create/destroy (data gone). Ludic has both, as
distinct verbs: `disable` pauses and keeps data; `detach` structurally removes
(a later `attach` re-seeds). This is exactly DOTS enableable-components vs
structural add/remove, and Bevy `disabled` vs `Remove`.
- **Hooks are typed annotations, not magic-named methods.** MonoBehaviour matches
`Awake`/`Update` by *string name* via reflection — a typo silently never runs.
Ludic's `@OnSpawn(Enemy)` is a checked reference; a wrong name is a compile
error. Preserve this.
What's missing is everything past "what happened": **why** it happened, **which
values changed**, **when composite conditions begin/end to hold**, and
**dependency-keyed** setup/teardown. That is the roadmap.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Unity MonoBehaviour** | Two-phase init with a global barrier (all `Awake` before any `Start`); repeatable enable-pair vs once create-pair. |
| **Unity DOTS** | *Data-driven activation*: `RequireForUpdate` + `OnStartRunning`/`OnStopRunning` — a system edge-triggers when its query starts/stops matching. Enableable components = cheap "logically off." |
| **Unreal** | *Reason-carrying teardown*: `EndPlay(EEndPlayReason)` — one teardown, branch on `Destroyed`/`LevelTransition`/`Quit`/…; forces enumerating every death path (no silent deaths). Provenance-tagged construction. |
| **Bevy** | Full structural event set Add/Insert/**Replace**/Remove/Despawn with strict order; **Replace exposes the old value before drop**. Hooks (type-level, singular, invariant) vs observers (plural, reactive). Declarative `before`/`after`/`chain` ordering. State `OnEnter`/`OnExit`/`OnTransition`. |
| **flecs** | `Monitor` observers fire on *composite query membership* start/stop. Events fire on **real transitions**, not every API call. Deferred-by-default with explicit sync points. |
| **EnTT** | `patch` as the *explicit mutation channel* that fires `on_update` (solves "raw writes are invisible"). Opt-in signals — zero cost when unused. |
| **Godot** | Tree membership *is* the lifecycle driver; enter top-down, **`_ready` bottom-up** (dependencies initialized first); `queue_free()` deferred safe-delete; `process_mode` pause inherited down the tree. |
| **Actor model (OTP/Akka)** | Lifecycle driven by *failure + supervision*: reason-carrying `terminate`, **restart as a state distinct from create/destroy** (stable identity, reset transient state), supervision trees, `code_change` = live state migration. |
| **Declarative UI (React/SwiftUI/Compose)** | *Paired setup/teardown keyed on a dependency list* — cleanup co-located with setup so it can't leak; an update **is** keyed teardown-then-setup; lifetime follows *identity*. |
| **RAII / Rust `Drop` / DI scopes** | *Scope = lifetime*: deterministic, reverse-construction-order, single-shot, no-resurrection teardown, guaranteed even on early exit; lifetime-mismatch checking (no long-lived thing holding a short-lived handle). |
Two recurring **footguns** the whole survey warns against, to design *out* of Ludic:
1. **Silent order-dependent reactivity.** Bevy's removal buffers are cleared at
end-of-frame, so a detector that runs before the mutator *misses removals
entirely*. If Ludic adds change/removal reactivity, make it either push-based
(fire at the mutation site — Ludic's natural style) or loudly order-checked.
2. **Invisible in-place writes.** flecs `on_set` and EnTT `on_update` don't fire
on a raw pointer write — you must call `modified()`/`patch`. Ludic can dodge
this entirely (see LC2): the compiler *sees* every write site.
---
## 4. LC0 — structural attach/detach + `@OnDetach` ✅ *shipped*
The one missing cell in §2's table. `attach P on e { overrides }` adds a property
to a **live** entity (seeding fields, firing `@OnAttach`); `detach P on e` removes
it (firing `@OnDetach`, which reads the outgoing value, before the has-flag
clears). Both fire only on a **real transition** (flecs/Bevy idempotent-add
semantics): re-attaching a present property or detaching an absent one is a no-op.
Lowering: `attach` guards on the has-flag and, when absent, reuses the existing
`emit_init_component` (seed + `@OnAttach`); `detach` guards on presence, clears the
flag, and fires `@OnDetach` with the property bound by name — the same binding the
`@OnDisable` path already uses. No new runtime; POD data stays in `@S_` storage.
See [`examples/lang/detach.ludic`](examples/lang/detach.ludic).
---
## 5. LC1 — reason-carrying teardown ✅ *shipped (`@OnDespawn`)*
The highest-conviction idea in the survey: it appears independently in Unreal
(`EndPlay`), Erlang (`terminate`), and Akka (`preRestart`), and Bevy has an open
issue asking for it. **Teardown should know *why*.** A destructor frequently needs
to branch — save on `Quit` but not on a scene swap, skip network cleanup when the
whole program is exiting.
`@OnDespawn` gains an optional bound **reason**:
```ludic
# doc-check: skip
# EndReason { Despawned, SceneExit, Quit } — the compiler owns this enum
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => {} # app closing — don't bother dropping loot
_ => drop_loot(Health.hp)
}
}
```
**What shipped.** The lowering is exactly the cheap desugars-to-code shape the
survey promises. The despawn hook compiles to `@on_despawn_<Model>(i32 %e, i32
%reason)`; when the hook writes `reason: r`, `r` is bound as an int local reading
`%reason`. Each teardown *site* passes a constant `EndReason`:
- `despawn e` passes `Despawned` (0) — an in-world death.
- **program shutdown** passes `Quit` (2): a generated `@L_despawn_all(reason)`
walks the live set at `done:` (before `@OnQuit`, matching the timeline) and
fires every survivor's `@OnDespawn`. This makes **"no silent deaths"** real —
an entity that outlives the run still gets its destructor, and can branch on
`Quit` to skip work that only matters mid-game. Emitted only when the program
has `@OnDespawn` hooks, so despawn-free programs are byte-for-byte unchanged.
- `SceneExit` (1) is reserved: a scene tearing down its owned entities
(SCENES-DESIGN E1) will pass it once scene-owned entities land.
`EndReason` is compiler-owned (resolved in `enum_ordinal`), so `EndReason.Quit`
works without a user declaration; a user enum of the same name still shadows it.
Backward-compatible: the `reason:` binding is optional, and `@OnDespawn` without
it is unchanged. `@OnDetach` and scene `on exit` do **not** yet take reasons
(§13.1). See [`examples/lang/reason.ludic`](examples/lang/reason.ludic).
---
## 6. LC2 — value-change hooks `@OnChange(P)` *(a compile-time win)*
Every reactive ECS wants "fire when a component's value changes" (flecs `on_set`,
EnTT `on_update`, Bevy `Changed<T>`), and every one hits the same footgun: a raw
in-place write is invisible, so you must route mutations through a special channel
(`modified()`, `patch`) or you miss changes.
**Ludic can sidestep the footgun because it is an AOT compiler that sees every
write site.** A field store `Health.hp = …` is a statement the compiler lowers; if
`Health` carries an `@OnChange`, the compiler can emit the hook call *right after
the store*. No dirty bits, no end-of-frame flush, no missed-write class of bugs —
the thing that is a runtime hazard everywhere else is resolved at compile time.
```ludic
# doc-check: skip
@OnChange(Health) handler Bar { hud_set_health(Health.hp) } # after any write to a Health field
```
Open question (§7): fire on *every* write (Bevy's `DerefMut` semantics — simple,
may over-fire) or guard with a value compare (fire only on actual change — needs
the old value, à la Bevy `Replace`). The compiler has the old value in hand at the
store site, so the value-compare form is feasible and is the more useful default.
---
## 7. LC3 — query-membership edges `@OnStartMatch` / `@OnStopMatch`
DOTS `OnStartRunning`/`OnStopRunning` and flecs `Monitor` fire when an entity
**starts or stops matching a composite query** — not a single component, but a
whole condition (`{Position, Velocity, moving}`). This is strictly more expressive
than per-property `@OnAttach`, which can't see "the entity now has *both* and is
alive." It's the natural ECS form of enter/exit.
```ludic
# doc-check: skip
@OnStartMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler BeginMoving { play("footstep_loop.wav") }
@OnStopMatch(these: [Position, Velocity{dx != 0 or dy != 0}], on: Actor)
handler StopMoving { stop("footstep_loop.wav") }
```
Cost: unlike LC1/LC2 this needs runtime state — a per-entity shadow bit per
monitored query ("did it match last tick?"), checked once per frame, edge-
triggering the hook on a change. flecs does this by evaluating the query against
the entity's previous and current archetype. Ludic would keep a `@M_<query>` bit
array parallel to `@H_`. Medium cost; a genuinely differentiated feature.
---
## 8. LC4 — keyed effects (paired setup/teardown on a dependency list)
The declarative-UI headline, and the biggest reach. React `useEffect`, Compose
`DisposableEffect`, and SwiftUI `.task` all express: *while this thing exists (or
while key K holds), set up a resource; when it leaves or K changes, tear it down*
— with cleanup **co-located** with setup so it can't leak, and an *update* defined
as keyed teardown-then-setup. This collapses create/update/destroy into one
primitive.
```ludic
# doc-check: skip — sketch
@Effect(on: Enemy, keys: [Sprite.id]) handler Body {
let tex = image_load(Sprite.id)
dispose { image_drop(tex) } # runs on despawn OR when Sprite.id changes
}
```
Semantics: the setup runs on spawn (and whenever a listed key changes, after the
previous `dispose`), and `dispose` runs on despawn (and before each keyed re-run).
It unifies `@OnAttach`/`@OnDetach`/`@OnChange` into one leak-proof unit. Lowering
needs somewhere to stash the effect's captured teardown state and last key values
per entity — a per-effect side table, re-checked in a phase. Design only; the
syntax and storage model are open. This is where Ludic could feel genuinely modern
relative to every ECS surveyed (none of which have it).
---
## 9. LC5 — deferred structural changes with commit points
DOTS `EntityCommandBuffer`, flecs `defer_begin/end`, and Godot `queue_free()` all
make structural change **deferred with an explicit commit point**, so mutating
while iterating is safe and batched. Ludic's `spawn`/`despawn` are immediate today,
but *already* iteration-safe by a different route — matching is lazy per entity id
([LANGUAGE.md](LANGUAGE.md) "Matching is lazy, not snapshotted"), so despawning the
current entity is defined. A `defer { … }` block (or `despawn e at LateUpdate`)
that queues structural changes to a phase boundary would add batching and a single
predictable commit point, and is the prerequisite for safe parallel handlers
(the `reads`/`writes` scheduling in SCENES-DESIGN). Design only; lower priority
than LC1–LC3 because the immediate path is already safe.
---
## 10. LC6 — supervision, restart-as-a-state, live migration
The furthest-out cluster, from the actor model and OTP: lifecycle driven by
**failure**, not just create/destroy. Three ideas, all tied to Ludic's eventual
hot-reload / bytecode-VM roadmap rather than the near term:
- **Restart as a distinct state** between create and destroy — preserve an
entity's identity, reset its transient components, re-run setup (respawn,
hot-reload). Akka's "stable external ref, replaced internal state."
- **Supervision / failure escalation** — a subsystem owner declares a policy for
child faults (restart one / restart the group / escalate to reload the scene)
instead of defensive inline checks. Ludic has no failure model yet, so this
waits on one.
- **Live state migration** (`code_change`) — a hook that transforms an entity's
persistent state across a code/schema version, so hot-reload evolves data
instead of destroying it. Directly relevant to a self-hosting language.
---
## 11. Design principles distilled from the footguns
1. **No silent deaths.** Enumerate every teardown reason (LC1). If the compiler
must name the reason at each site, it can't forget a path.
2. **Fire on real transitions, not API calls.** Idempotent add/remove — LC0
already does this; keep it for every future hook.
3. **Keep "paused" and "gone" distinct.** `disable`/`enable` (data kept) vs
`detach`/`attach` (structural) — already true; don't let a future feature blur
them.
4. **Prefer compile-time resolution to runtime tracking.** LC2 turns the
universal "invisible write" footgun into a compile-time hook emission because
Ludic sees write sites. Reach for this wherever a runtime dirty-bit is the
obvious-but-worse option.
5. **If reactivity is order-dependent, make it loud.** Never silently drop events
at a frame boundary (Bevy's removal-buffer trap). Ludic's push-at-the-site
style avoids this by default.
6. **Deterministic teardown order.** When a scope tears down many things (a scene
unloading its owned entities — SCENES-DESIGN E1), define the order (reverse of
creation, RAII-style) rather than leaving it unspecified.
7. **Only the semantic layer.** POD components mean no ctor/dtor/move hooks. Don't
grow a memory-lifecycle layer Ludic doesn't need.
---
## 12. Suggested implementation order
- **LC0 — attach/detach + `@OnDetach`.** ✅ Done. Closes the structural pair.
- **LC1 — reason-carrying teardown.** ✅ Done for `@OnDespawn` (an `i32 %reason`
param + a constant at each site, plus a shutdown despawn-all for `Quit`).
`@OnDetach` / `on exit` reasons remain open (§13.1).
- **LC2 — `@OnChange(P)`.** Compile-time hook emission at write sites — a
Ludic-specific win over every ECS's invisible-write footgun. **Recommended next.**
- **LC3 — `@OnStartMatch`/`@OnStopMatch`.** First feature needing runtime shadow
state; the expressive ECS enter/exit.
- **LC4 — keyed effects.** The modern, leak-proof unification. Design first.
- **LC5 — deferred structural changes.** Batching + parallel-safety; the immediate
path is already iteration-safe, so lower urgency.
- **LC6 — supervision / restart / migration.** Waits on a failure model and the
hot-reload roadmap.
---
## 13. Open decisions
1. **Reason enum (LC1):** *resolved for `@OnDespawn`* — ships `Despawned`,
`SceneExit`, `Quit` as a compiler-owned `EndReason`, passed as an optional
`reason:` binding (not a separate annotation). Still open: `SceneExit` has no
firing site until scene-owned entities (SCENES-DESIGN E1); should `@OnDetach`
and scene `on exit` take reasons too, and if so with which reason values?
2. **`@OnChange` (LC2):** fire on every write (simple, over-fires) or only on an
actual value change (needs the old value at the store site)? Per-field or
whole-property granularity?
3. **Membership edges (LC3):** where do the shadow bits live, and is the check
per-frame or event-driven off attach/detach/spawn? Cost budget.
4. **Keyed effects (LC4):** syntax (`@Effect` annotation vs an `effect { … dispose
{ … } }` statement), and where per-entity teardown/key state is stored.
5. **Ordering:** none of this addresses intra-phase handler ordering (Bevy
`before`/`after`, flecs `DependsOn`). Worth a separate proposal; declarative
relational ordering over priority integers, per the survey.
---
*Companion to [LANGUAGE.md §Annotations](LANGUAGE.md) and
[SCENES-DESIGN.md](SCENES-DESIGN.md) (scene-owned entities and reasons intersect at
LC1/LC5). Supersedes nothing until the compiler work in §12 lands.*

File diff suppressed because it is too large Load diff

View file

@ -1,338 +0,0 @@
# iOS & Android — a design doc
> **Status: all design, nothing shipped.** Ludic builds windowed on macOS
> (`runtime/native/cocoa.ll`) and has a documented — but currently un-reimplemented
> — wasm32 web target. iOS and Android are not buildable today, and the
> cross-compile plumbing that would target them died with the C driver. This doc
> lays out the whole path so we can decide the shape before building any of it. The
> headline decision (§7): render on the **GPU via `extern fn` FFI**, not the CPU
> framebuffer. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program compiles to LLVM IR, then clang assembles and links it. The
platform story has **two independent axes**, and it's essential not to conflate
them:
| Axis | What it is | State today |
|---|---|---|
| **Target** (triple + toolchain) | how IR becomes a runnable binary for an OS/arch | barely plumbed — no `--target`, no emitted `target triple`, host-only |
| **Platform runtime** (window/input/present) | one file implementing the 5-function window protocol | well-factored — `cocoa.ll` is ~328 lines, swappable |
**What exists:**
- The window seam is exactly five functions — `win_open` / `win_poll` /
`win_present` / `win_running` / `win_close` — declared by the compiler
([emit_head.ludic:58](selfhost/emit_head.ludic:58)) and lowered as intrinsics
([emit_intrin2.ludic:39](selfhost/emit_intrin2.ludic:39)). The runtime calls them
through `rt_*` wrappers ([core.ludic:48](runtime/native/core.ludic:48),
[:103](runtime/native/core.ludic:103), [:217](runtime/native/core.ludic:217)).
`COMPILING.md` states the intent plainly: a new platform is "another `.ll` file
with the same five entry points and no compiler change."
- **`extern fn` FFI is real and live** — `extern function c_hypot(a: fixed, b: fixed) ->
fixed = "hypot_fx"` ([LANGUAGE.md:565](LANGUAGE.md:565)), with a full pipeline:
parse ([parse_game.ludic:236](selfhost/parse_game.ludic:236)) → call lowering to a
direct `call @<sym>` ([emit_expr.ludic:168](selfhost/emit_expr.ludic:168)) →
`declare` emission ([emit_head.ludic:105](selfhost/emit_head.ludic:105)). Working
examples: [examples/networking/net_echo.ludic:12](examples/networking/net_echo.ludic:12),
[examples/library/arena.ludic:14](examples/library/arena.ludic:14). This is the single
most important fact in this document — see §7.
**What's missing (all of it must be built):**
| Gap | Why mobile needs it |
|---|---|
| `--target <triple>` flag + emitted `target triple`/`datalayout` | iOS = `aarch64-apple-ios`, Android = `aarch64-linux-android`; both are cross-compiles |
| per-target `size_t` width (i32/i64) | already a known wasm trap; every allocation sizing depends on it |
| **OS-owned frame loop** (`ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`) | iOS (CADisplayLink) and Android (Choreographer) own the loop — you cannot `while(alive)` |
| per-platform window shim + touch input | UIKit/`CAMetalLayer`, Android `Surface`/NDK; input is touch, not a keycode |
| SDK sysroot + packaging + signing | `.app` bundle / `.apk`, not a bare executable |
The frame-loop gap is shared with the web target — `tools/ludic-web/run.mjs`
already expects `ludic_boot`/`ludic_frame`, but the self-hosted emitter only
produces a monolithic `@main` ([emit_game.ludic:685](selfhost/emit_game.ludic:685)).
So the wasm path is half-broken for the same reason mobile can't exist yet.
---
## 2. Design principles
1. **Two axes, kept separate.** "Add a platform" = a cross-compile *target* plus a
platform *runtime*. Muddling them is why this looks bigger than it is. Most of
the compiler work (§4, §5) is target plumbing that serves web, iOS, and Android
at once; the per-OS work (§6) is genuinely small by design.
2. **The OS owns the loop — so we must too.** Mobile, like the browser, forbids an
inline frame loop. Rather than special-case mobile, adopt the frame-driven model
*everywhere* the OS demands it, from one emitter change. This is the keystone.
3. **The GPU is an ABI to call, not a program to compile.** `extern fn` already
binds C libraries; bind GL ES / Metal the same way. No IR-per-API (the `cocoa.ll`
route — 328 lines for *five* functions), no per-symbol intrinsics. The roadmap
reaches this conclusion independently ([LUANTI-ROADMAP.md:1083](LUANTI-ROADMAP.md:1083),
[:1375](LUANTI-ROADMAP.md:1375)).
4. **The 2D stack stays byte-identical.** The framebuffer graphics
(`rt_fb` + all `rt_*`/`image`/`truetype`/`ui` primitives) keep working
unchanged. GPU rendering is *additive*: 2D composites as one texture on top of
GPU 3D. Nothing above the window seam is rewritten.
---
## 3. Core model
Everything below reduces to plumbing one new flag through the compiler and swapping
two runtime files per OS. The mental model:
```
ludicc app.ludic --target aarch64-apple-ios -o app
│
├─ emit_head: target triple / datalayout / size_t width (§4)
├─ emit_game: ludic_boot/frame/alive/teardown not @main (§5)
├─ link: runtime/ios/uikit.ll + gfx3d.ldylib (§6, §7)
└─ package: .app bundle + codesign (§8)
```
The game source and the entire ECS/graphics/UI stack compile **unchanged** for
every target. Only the head declarations, the entry-point shape, the linked
platform file, and the packaging step vary.
---
## 4. Extension M1 — the target axis: `--target`, triple, `size_t`
Today [main.ludic:66](selfhost/main.ludic:66) parses `--windowed`/`--headless`/
`--emit-llvm`/… and nothing selects an arch; the IR carries no `target triple`, so
native inherits clang's host default and the only explicit triple in the tree is
`wasm32-unknown-unknown` ([runtime/web/wasm.ll:23](runtime/web/wasm.ll:23)).
Proposal: a `--target <triple>` flag that drives three things.
```
ludicc app.ludic --target aarch64-apple-ios -o app
ludicc app.ludic --target aarch64-apple-ios-simulator -o app # x86_64 host → arm64 sim varies
ludicc app.ludic --target aarch64-linux-android -o libapp.so
```
- **Emit the triple + datalayout.** `emit_header`
([emit_head.ludic:37](selfhost/emit_head.ludic:37)) gains a `target triple = …`
/ `target datalayout = …` line, chosen from a small table keyed on `--target`.
Absent the flag, emit nothing (host default) — keeps existing native builds
byte-identical.
- **Per-target `size_t` width.** wasm32 already needs `i32` sizes; the same helper
discipline (`ll_size_t`/`ll_widen`/`ll_narrow`, per the web-backend notes) applies
to any 32-bit target. iOS/Android arm64 are LP64 like macOS, so `i64` — but the
flag must *select* the width, not assume the host's.
- **Toolchain construction.** The linker command
([main.ludic:130](selfhost/main.ludic:130)) becomes target-conditional: an SDK
sysroot (`-isysroot`/`--sysroot`), the platform `.ll`, and target-specific link
flags (§8). `$LUDIC_CC` still overrides; add `$LUDIC_SYSROOT_<target>` for the
SDK path so CI and local machines can differ.
This axis is **shared with reviving wasm** — do it once, three targets benefit.
---
## 5. Extension M2 — the OS-owned frame loop (the keystone)
A native build emits `@main` with the frame loop inline — an `rt_init`, then a
`loop:`/`done:` block calling `rt_poll`/`rt_running`
([emit_game.ludic:685](selfhost/emit_game.ludic:685)). **iOS and Android cannot run
this.** UIKit calls back into your code once per display refresh (CADisplayLink);
Android's Choreographer does the same; the browser's `requestAnimationFrame` already
does. In all three the OS owns the loop and calls *you*.
Proposal: emit four exported functions instead of an inline-loop `@main`, exactly
as `COMPILING.md` already describes and `run.mjs` already expects:
```
ludic_boot() → rt_init (once)
ludic_frame() → rt_poll · systems · rt_present (per OS callback)
ludic_alive() → i1 → rt_running (OS asks: keep going?)
ludic_teardown() → rt_shutdown (once)
```
- **`@main` becomes the composed default, not the only shape.** For host desktop
and headless, the compiler synthesizes an `@main` that *calls* the four in an
inline loop — so native/headless output is unchanged in behavior. For
OS-owned-loop targets (`--target` is wasm/ios/android, or a new
`--loop=external` mode), emit only the four exports and no driving `@main`.
- **One emitter change, three targets fixed.** This simultaneously un-breaks the
web target (whose runner already calls these) and unlocks both mobile OSes. It is
the highest-leverage change in this doc.
- **State stays where it is.** The four functions close over the same globals
`rt_init`/`rt_poll`/`rt_running`/`rt_shutdown` already touch
([core.ludic:48](runtime/native/core.ludic:48)); no new runtime state, no heap.
---
## 6. Extension M3 — the per-OS window shim + touch input
Each OS gets one platform file implementing the five-function seam, modeled on
`cocoa.ll` but rewritten for its UI toolkit. This is the part the codebase is
explicitly built for.
- **iOS — `runtime/ios/uikit.ll` (or a thin `.m` shim).** `win_open` creates a
`UIWindow` + a `UIViewController` whose view is a `CAMetalLayer`/`MTKView`;
`win_present` presents the current drawable; the loop is driven by M2's
`ludic_frame` from a `CADisplayLink`, so `win_poll`/`win_running` adapt to the
callback model rather than a spin. Hand-written IR against `objc_msgSend` is
possible (it's how `cocoa.ll` works) but a small compiled `.m` linked in is more
maintainable for UIKit's larger surface — an open decision (§11).
- **Android — `runtime/android/ndk.ll` + a Kotlin/Java `Activity` host.** The
native code is a `.so` loaded by an `Activity`; the window is an
`ANativeWindow`/`Surface` obtained via `GameActivity`/NDK, GPU via EGL + GL ES.
Frames are driven by Choreographer through JNI into `ludic_frame`.
- **Touch input changes the input seam.** `win_poll()` returns a single `int`
keycode today ([emit_intrin2.ludic:41](selfhost/emit_intrin2.ludic:41),
[core.ludic:217](runtime/native/core.ludic:217)) — insufficient for touch, which
needs `(x, y, phase, id)`. Options: (a) a parallel `win_poll_touch() -> pointer`
draining an event queue, or (b) widen the input model to a small event struct for
all platforms. This is the one place mobile forces a decision above the window
seam. Proposed: add touch as a **separate** seam so keyboard platforms stay
untouched and byte-identical.
Everything above the seam — framebuffer, PNG sprites, TrueType, retained UI — is
portable Ludic and compiles unchanged.
---
## 7. Extension M4 — GPU rendering via `extern fn` (the headline)
Today **all** drawing writes into one CPU framebuffer: `rt_fb`, a
`words(320*240)` buffer of `0x00RRGGBB` i32 pixels
([core.ludic:23](runtime/native/core.ludic:23)), written by every primitive
(`rt_clear`/`rt_fill_rect`/glyphs/`rt_blend_px`/`tt_blit`/UI) and handed whole to
`win_present`. `cocoa.ll` blits it through CoreGraphics —
`CGBitmapContextCreate`→`CGImage`→`CGContextDrawImage` inside `@ludic_drawRect`
([cocoa.ll:94](runtime/native/cocoa.ll:94)). There is no GPU context anywhere.
Because **`extern fn` already exists**, binding the GPU is ordinary runtime code —
no new language feature, no new intrinsic:
```ludic
# doc-check: skip — runtime/native/gfx3d.ludic, illustrative
extern function gl_gen_textures(n: int, out: pointer) -> void = "glGenTextures"
extern function gl_tex_image_2d(t: int, w: int, h: int, px: pointer) -> void = "gl_tex_image_2d"
extern function gl_draw_elements(mode: int, count: int, ty: int, idx: pointer) -> void = "glDrawElements"
```
Two phases, additive:
1. **Framebuffer-as-texture (drop-in).** Keep the entire 2D stack. `rt_present`
([core.ludic:103](runtime/native/core.ludic:103)) uploads `rt_fb` as one texture
and draws a full-screen quad. The `win_present(fb,w,h)` signature is unchanged;
only the pixel-delivery core of the platform file differs (texture upload instead
of CoreGraphics blit). This is the minimum viable GPU path and gets mobile on
screen with zero changes above the seam.
2. **True GPU 3D (additive).** Geometry goes straight to GL/Metal via `gfx3d.ludic`
`extern fn` calls; the CPU framebuffer is reused only for the 2D UI overlay,
composited as a texture on top. New GPU-draw entry points live in `gfx3d.ludic`
as `extern fn`s — the five-function window protocol does **not** widen.
Language-level cost is narrow and already scoped by the roadmap:
- **`f32`** (roadmap gate G-04) for vertex/matrix data — the *only* hard language
dependency ([LUANTI-ROADMAP.md:1087](LUANTI-ROADMAP.md:1087)).
- Optional vector operator overloading for `v3f`/`m4` ergonomics (G-29,
[:1107](LUANTI-ROADMAP.md:1107)) — a "nicer, not necessary."
The roadmap's own decision is explicit: FFI over IR-per-API, because "`cocoa.ll`
is 327 lines for *five* window functions — OpenGL has hundreds of entry points"
([LUANTI-ROADMAP.md:1375](LUANTI-ROADMAP.md:1375)).
---
## 8. Extension M5 — packaging, SDKs, and signing
The current driver is one `clang` call ([main.ludic:130](selfhost/main.ludic:130))
producing a bare binary. Mobile output is a bundle, and this is where most
real-world friction lives — it is deliberately the *last* phase.
- **iOS.** Cross-compile with the iPhoneOS SDK sysroot → an executable, wrap in an
`App.app` bundle with an `Info.plist`, `codesign` with a development identity,
install to simulator/device. Simulator is the cheap inner loop
(`aarch64-apple-ios-simulator`); device needs a provisioning profile. ludicc
should emit the binary and shell a packaging step (or emit a manifest a small
script consumes), not learn Xcode's project format.
- **Android.** Cross-compile with the NDK → `libapp.so`, drop it into a minimal
Gradle/Kotlin `Activity` shell, build the `.apk`/`.aab`, sign with a keystore.
The `Activity` is fixed boilerplate that ships in the repo (`runtime/android/`),
parameterized by app name/id.
- **Keep the compiler out of it.** Both flows are "produce native code + assemble a
package around it." The compiler's job ends at the object/`.so`; a `--package`
step or an external `build-mobile.sh` owns the bundle. This mirrors how ludicc
already drives clang without becoming a build system.
---
## 9. Lowering / build summary
| Construct | Reduces to |
|---|---|
| `--target <triple>` (M1) | a triple/datalayout line in `emit_header` + a `size_t`-width choice + target-conditional link command |
| OS-owned loop (M2) | emit `ludic_boot`/`ludic_frame`/`ludic_alive`/`ludic_teardown`; host/headless get a synthesized `@main` calling them |
| window shim (M3) | one `.ll`/shim per OS implementing the same five `win_*` intrinsics; no compiler change |
| touch input (M3) | a **new, separate** input seam (`win_poll_touch`), so keycode platforms stay byte-identical |
| framebuffer→texture (M4.1) | `rt_present` uploads `rt_fb` as a texture + full-screen quad; `win_present` signature unchanged |
| GPU 3D (M4.2) | `extern fn` calls in `runtime/native/gfx3d.ludic` — data in `prog`, zero compiler edits, needs only `f32` |
| packaging (M5) | binary/`.so` unchanged; an external `--package`/script builds `.app`/`.apk` and signs |
No new allocator, no new dispatch, no per-API intrinsics. The game and the 2D
graphics stack compile identically for every target; only head declarations, the
entry-point shape, the linked platform file, and packaging vary.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **M0 — target axis** (M1) + **revive the OS-owned loop** (M2). *Do these first
and together* — they're the shared compiler plumbing, they un-break the existing
web target (proving the frame-loop split against `run.mjs`/`bin/x test` before any
mobile SDK is involved), and they need no mobile toolchain. This is the floor.
- **M1 — iOS simulator, framebuffer-as-texture** (M3 iOS shim + M4.1). First pixels
on a phone, GL/Metal binding proven, no signing/device friction yet.
- **M2 — iOS device** (M5 iOS packaging + signing).
- **M3 — Android** (M3 Android shim + M4.1 + M5 Android packaging), reusing every
M0 change.
- **M4 — `f32` + GPU 3D** (M4.2), gated on roadmap G-04; the additive 3D path over
`gfx3d.ludic`.
- **M5 (later) — touch-input model** hardening (M3), gesture/multitouch, once a real
app exercises it.
M0 is the honest prerequisite and the highest-leverage work — it serves three
targets and revives a fourth. M1 is the first thing anyone can *see*.
---
## 11. Open decisions
1. **Loop selection:** does `--target ios/android/wasm` *imply* the external loop,
or is there an explicit `--loop=external` flag? (Proposed: implied by target,
with the flag as an override for headless testing.)
2. **iOS shim language:** hand-written `.ll` against `objc_msgSend` like `cocoa.ll`,
or a small compiled `.m`? (Proposed: `.m` — UIKit's surface is too large for
maintainable IR, and Metal setup is verbose.)
3. **Touch seam shape:** a separate `win_poll_touch` queue, or a unified event
struct replacing the keycode `win_poll` on all platforms? (Proposed: separate,
to keep desktop/web byte-identical.)
4. **GPU API baseline:** GL ES 3.0 everywhere (Android native, iOS via ANGLE/Metal
translation), or Metal on iOS + GL ES on Android from day one? (Proposed: GL ES
3.0 first for a single codepath; Metal later.)
5. **Android host:** ship a fixed Kotlin `GameActivity` in `runtime/android/`, or
generate it per app? (Proposed: fixed boilerplate, parameterized by name/id.)
6. **Packaging home:** a `--package` step inside ludicc, or an external
`build-mobile.sh`? (Proposed: external script; keep the compiler out of bundle
formats.)
7. **`size_t` for arm64:** confirm iOS/Android arm64 are LP64 (`i64`) in the width
table, and that the `ll_size_t` discipline covers every new size-taking call.
8. **Simulator arch:** how to handle `aarch64-apple-ios-simulator` vs. x86_64 sim on
Intel hosts in the target table.
---
*Companion to [COMPILING.md](COMPILING.md) (§ toolchain, the wasm frame-loop
split), [LANGUAGE.md §"Functions & FFI"](LANGUAGE.md:560) (`extern fn`), and
[LUANTI-ROADMAP.md](LUANTI-ROADMAP.md) (G-04 `f32`, G-28 GPU FFI, G-29 3D math).
Supersedes nothing until the M0 compiler work lands.*

View file

@ -1,505 +0,0 @@
# Networking, from primitives up — a design doc
> **Status: N0–N6 all shipped.** The whole stack is implemented and self-hosted,
> and — unlike the original N0/N1 which linked C hosts — every phase now runs as a
> self-contained **pure-Ludic** program (no `.c`, no foreign host): a built-in
> loopback transport fills the seam, and each `examples/net_*.ludic` drives and
> asserts itself from its own `entry`. See `bin/x test` (checks `net_echo` … `net_demo`)
> and `examples/networking/net_demo.ludic` for a full RPC→authority→replicate→reconcile loop.
> clang remains only as the LLVM-IR assembler/linker (no C is compiled), the floor
> Rust and Swift stand on.
>
> _Historical note:_ **N0 + N1 shipped first; N2–N6 were design.** Two phases landed as `bin/x test`
> checks. **N0 (transport seam):** `extern fn` now lowers end to end — a direct
> `@<sym>` call plus a `declare`, no networking logic in the compiler — so the whole
> transport is two externs (`net_send`/`net_poll`) a host fills. Proven by
> [`examples/networking/net_echo.ludic`](examples/networking/net_echo.ludic) sending four bytes through
> the loopback host in [`tests/net_c/loopback.c`](tests/net_c/loopback.c) and
> polling them back (`4 10 20 30 42`). **N1 (snapshot-to-buffer):**
> `world_size()`/`world_save(buf)`/`world_load(buf, len)` generalize `save()`/`load()`
> from a file to a caller-owned memory buffer — the same block layout via `memcpy` —
> so the whole ECS world round-trips through bytes. Proven by
> [`examples/networking/net_snapshot.ludic`](examples/networking/net_snapshot.ludic) +
> [`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) (snapshot, mutate,
> restore → `50 7 50`). Both are byte-identical when unused, so the offline dividend
> (§8) holds. This is a companion to
> [EVENTS-DESIGN.md](EVENTS-DESIGN.md), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md),
> and [SCENES-DESIGN.md](SCENES-DESIGN.md). Where the events work made Ludic
> *moddable*, this proposes making it *networked* — and it deliberately does **not**
> ship a multiplayer framework. Ludic is a language: it exposes the low-level
> mechanism (transport seam, world snapshot, generated serializers, ownership, a
> drivable sim) and a thin high-level *declarative* layer that lowers onto that
> mechanism, and it leaves the netcode *policy* (authority, prediction, relevancy)
> to the developer or a library. §14 lists the open decisions.
---
## 1. Thesis
Every networking model dies on one of two problems: **determinism** or **state
serialization**. Ludic already solves both, almost by accident.
- **Determinism** is designed in — seeded RNG, `fixed` (Q16.16) instead of floats,
byte-identical golden renders, and (as of [EVENTS-DESIGN EV6](EVENTS-DESIGN.md))
bounded, array-ordered event dispatch. A modded, event-driven Ludic game still
replays identically. That is exactly the property lockstep multiplayer needs, and
the reason Factorio's heavily-modded multiplayer stays in sync.
- **State serialization** already exists — `save()`/`load()` snapshot the *entire*
ECS World to a byte buffer ([`selfhost/emit_save.ludic`](selfhost/emit_save.ludic)),
and the world-table schema built for [EVENTS-DESIGN EV2](EVENTS-DESIGN.md) (prop →
field → offset) is exactly the descriptor you serialize against.
So networking is not a new subsystem. It is a **fourth lens on the event + world
layer** — the same layer modding used. And it obeys the same two-altitude rule as
everything else in Ludic:
> **Low-level is freedom; high-level is developer experience; they are the same
> feature at two altitudes.** `@Queries` lowers to a query loop, `scene` lowers to a
> machine, `@Public @OnSpawn` lowers to `emit`. Networking's high-level annotations
> lower to a transport seam, generated serializers, and a drivable sim — and the
> primitives stay exposed underneath for anyone the sugar doesn't fit.
The developer writes **one simulation**, declares *what* replicates, *who* owns
each entity, and *where* each handler runs — and never branches on `is_server()`
in ordinary code. The compiler lowers the declarations; a networking *runtime*
(the seam-filler, like `rt_*` for windowing) supplies the transport and the tick.
---
## 2. Two altitudes, one system
| Altitude | Who writes it | Surface |
|---|---|---|
| **High-level (DX)** | the developer, declaratively | `@Sync` (field/property/model), `@Owned`, `@Server`/`@Predicted`, directional remote events |
| **Lowering** | the compiler | per-model serializers, role-guarded dispatch, remote-event send/recv, ownership storage |
| **Runtime seam** | a networking library (blessed or custom) | binds the socket, sets `role`, drives the replication tick |
| **Low-level (freedom)** | power users, when the sugar doesn't fit | `net_send`/`net_poll`, `world_save`/`world_load`, generated `serialize_*`/`apply_*`, `owner()`, the drivable sim |
Everyone lives at the top row for normal games; the bottom row stays open for
someone building something no framework could express. The split that keeps this a
*language* and not a *framework*: **annotations and their lowering are the language;
the replication driver and the transport are a library.** It is precisely the
events story — `@On`/`emit` are the language, the *modding system* is library code —
applied again.
---
## 3. Research digest — the one idea to steal from each
| System | The transferable idea |
|---|---|
| **Quake / QuakeWorld** | The founding pattern: **client-side prediction + server reconciliation**, and delta-compressed snapshots against the last acked baseline. Predict locally, correct from the authority. |
| **Source (Valve)** | **Entity interpolation** (render remote entities slightly in the past, smoothly) paired with **lag compensation** (the server rewinds to the shooter's view for hit detection). Interpolation and rewind are two halves of one clock discipline. |
| **Unity NGO** (GameObject) | `NetworkVariable<T>` with **read/write permissions** + `OnValueChanged`; ownership as `OwnerClientId`. Also the **anti-pattern to avoid**: `IsServer`/`IsOwner` branching sprinkled through gameplay code. |
| **Unity Netcode for Entities** (ghosts) | The model Ludic is closest to: **replication is a compile-time property of components and fields** — `[GhostField]`, `[GhostComponent]`, `GhostOwner`, and `Predicted`/`Interpolated` ghost modes — with serializers *generated* from the ECS schema. |
| **Mirror / FishNet** | The community-ergonomic take: `SyncVar` with change **hooks**, and clean **directional RPCs** — `Command` (client→server) / `ClientRpc` (server→clients). |
| **GGPO / rollback** | Save state → predict → on misprediction **restore and re-simulate**. Its one hard requirement is *cheap, complete state snapshot/restore* — which Ludic already has in `save()`/`load()`. |
| **Factorio** | Fully **deterministic lockstep** for heavy mod multiplayer: only *inputs* cross the wire; the whole sim is reproduced. Proof that determinism (EV6) is the enabler, not a nicety. |
| **Photon Quantum** | A shipping product that *is* deterministic-ECS-rollback. Validates the exact combination — ECS + determinism + rollback — Ludic is already positioned for. |
| **Roblox** | The **local/remote split** (`BindableEvent` vs `RemoteEvent`), server-authority by default, and engine-replicated properties: "some state just replicates, and RPCs are directional events." |
Six **footguns** the survey warns against, to design *out* from the start:
1. **Role branching everywhere.** `if (IsServer)` scattered through gameplay is the
NGO readability tax. Fix: **role is a handler annotation** (`@Server`/`@Predicted`),
never a runtime branch in ordinary code.
2. **Float nondeterminism.** Lockstep breaks the instant the networked sim touches
`f32` across platforms. Fix: the determinism contract (§11) — the networked sim
stays `int`/`fixed`.
3. **Replicating pointers / heap refs.** A `ptr` field holds a machine-local
address; it cannot cross the wire. Fix: **the compiler rejects `@Sync` on a
non-POD-scalar field** — a checked guarantee, not a convention.
4. **Sending everything every tick.** Fix: `@Sync` is **opt-in at the field level**
(only marked fields replicate), plus change-driven dirty tracking (`@OnChange`,
[LIFECYCLE LC2](LIFECYCLE-DESIGN.md)) so an unchanged field costs nothing.
5. **Hidden authority.** Magic "the server decides" behavior is unclear and
unauditable. Fix: **explicit** `@Server`/`@Predicted`; unmarked code runs
everywhere by definition.
6. **Schema-less snapshots.** A raw state blob with no version desyncs silently on a
version mismatch. Fix: the **world-table schema is the versioned descriptor** the
serializer is generated against.
---
## 4. What Ludic already has
The substrate is unusually complete for an engine that has never networked:
- **A deterministic simulation** — seeded RNG, `fixed` math, ordered ECS iteration,
EV6-bounded event dispatch. Lockstep's precondition.
- **World snapshot/restore** — `save()`/`load()` serialize the whole World
([emit_save.ludic](selfhost/emit_save.ludic)); today to a file, trivially
retargetable to a memory buffer. Rollback's precondition.
- **A reflective world table** — `ludic_get`/`set`/`has`/`query`/`register_prop`
and the prop→field→offset schema (EV2/EV2b). The apply-and-serialize substrate.
- **An event bus with a foreign ABI and POD payloads** (EV0). Directional remote
events (RPCs) are one flag on this.
- **The `rt_*` seam pattern** — the compiler already emits calls to
`rt_init`/`rt_poll`/`rt_present` that a runtime library fills. Networking's
transport and role registers plug into the identical seam.
What is missing is small and named: a transport seam, snapshot-to-*buffer*,
generated per-field serializers, ownership storage, role-guarded dispatch, and a
developer-drivable loop. Each is a phase in §13.
---
## 5. The low-level primitives (the freedom layer)
Unopinionated, composable, host- or developer-owned. A power user builds any model
directly from these; the high-level layer (§6) is sugar over them.
| Primitive | Signature (sketch) | Enables |
|---|---|---|
| **Transport seam** | `extern function net_send(peer: int, buf: pointer, len: int)` · `extern function net_poll(buf: pointer, cap: int) -> int` | any model; host binds UDP (native) or WebRTC/WebSocket (wasm), or a loopback for tests |
| **World snapshot ↔ buffer** | `world_save(buf: pointer) -> int` · `world_load(buf: pointer, len: int)` | rollback, replication, join/resync — generalizes `save()`/`load()` off the filesystem |
| **Generated serializers** | `serialize_<Model>(e: entity, buf: pointer) -> int` · `apply_<Model>(e: entity, buf: pointer, len: int)` | per-model, touch only the `@Sync` fields; emitted from the schema |
| **Ownership** | `owner(e: entity) -> int` · `set_owner(e: entity, id: int)` | authority checks, per-entity owner metadata (an `@L_owner` array, like `@L_kind`) |
| **Role registers** | `is_server() -> bool` · `is_owner(e: entity) -> bool` · `local_id() -> int` | the runtime sets these; role-guarded dispatch reads them |
| **Drivable sim** | `tick_fixed()` · `tick_render()` · seed get/set | a developer-owned loop for prediction/rollback (also: replay, headless tests, AI) |
| **Remote-event serde** | `emit`-site serialize + `net_send`; inbound bytes rebuild + re-`emit` | RPCs |
Transport is the one that needs *no* language work at all — a developer can already
`extern fn` a socket library and link it, exactly as the windowing layer is linked.
The language's genuine contributions are snapshot-to-buffer, the generated
serializers, ownership storage, and the drivable loop.
```ludic
# doc-check: skip — the freedom layer, a hand-rolled replication tick
entry {
while running() {
if is_server() {
for (Transform) in query [Transform, Owned] {
let n = serialize_Player(self(), buf) # compiler-generated
net_send(ALL, buf, n) # developer's transport
}
} else {
let n = net_poll(buf, CAP)
if n > 0 { apply_Player(target_of(buf), buf, n) }
}
tick_render(); present()
}
}
```
This *works*, but it is deliberately not how most games should be written — it puts
serialization and role branching in the developer's face. That is what §6 fixes.
---
## 6. The high-level DX layer (the default)
The developer declares **what** replicates, **who** owns, and **where** handlers
run. No serialization, no transport, no `is_server()` in ordinary code.
### 6.1 `@Sync` — what replicates, at three granularities
Replication is **opt-in at the field level**: a field crosses the wire only when it
is explicitly marked. There is no `@NoSync` — the surface is purely additive.
Two independent switches, and **both must be on** for a field to replicate:
1. **A field is *replicable*** iff it is `@Sync`-marked — directly
(`@Sync hp: int`), or via `@Sync property P { … }` (a shorthand that marks
*every* field of `P` replicable). *Only marked fields — never all-by-default.*
2. **A component *participates* in a model** iff the model marks it `@Sync`
(`@Sync Transform` inside the `model`). Participation is decided **per model
use-site**, so the same property syncs in one model and not another.
A field of an entity replicates **iff it is replicable AND its component
participates in that entity's model.**
```ludic
# doc-check: skip — the three levels
@Sync property Position { x: int, y: int } # every field of Position is replicable
property Health { @Sync hp: int, max: int } # only hp is replicable; max never is
property Transform { @Sync x: int, @Sync y: int, angle: int } # x, y replicable; angle not
@Owned model Player { # entities carry a network owner
@Sync Transform # participates → replicates x, y (not angle)
@Sync Health # participates → replicates hp (not max)
@Sync Position # participates → replicates x, y
}
model Prop { # a non-owned decoration
Transform # not @Sync here → Transform does NOT replicate — the
# "non-synced Transform sometimes" case, for free
}
```
- **Checked, not silent.** `@Sync` on a `ptr`/non-POD-scalar field is a **compile
error** ("networked fields must be POD scalars" — footgun 3). A model that
`@Sync`es a component with *zero* replicable fields is a **compile warning**
(participation that replicates nothing).
- **Per-field direction** rides the same annotation as an argument, mirroring how
`@Queries(these:…, on:…)` takes args: `@Sync(to: owner) hp: int` replicates a
field only to the entity's owner (Unity's `SendToOwner`). Default is `to: all`.
### 6.2 Roles — where a handler runs
The role is a **declarative annotation on the handler**, never a runtime branch.
Unmarked code is the shared, deterministic simulation and runs everywhere.
| Annotation | Runs where | Meaning |
|---|---|---|
| *(none)* | everywhere | shared, deterministic simulation |
| **`@Server`** | the authority only | server-authoritative logic; clients receive the result via `@Sync` |
| **`@Predicted`** | the owning client (speculatively) **and** the server (authoritatively) | responsive local control, auto-reconciled against the server |
`@Predicted` is **explicit** — the developer opts an owned entity's control handlers
into prediction; the language does not silently predict. The name states the netcode
role (owner-predicts + server-authoritative + reconcile), not the machine, and
matches Unity's `GhostMode.Predicted` so the concept transfers.
`@Interpolated` — how a *non-owned* synced component is smoothed between snapshots on
a remote client — is a **presentation** concern on the component, kept separate from
these sim-handler roles rather than muddying them.
### 6.3 Ownership
```ludic
# doc-check: skip
@Owned model Player { @Sync Transform; @Sync Health } # every Player entity has a network owner
```
`@Owned` gives the model an owner slot (the `@L_owner` array); `owner(e)` /
`set_owner(e, id)` read and assign it (the authority assigns). `is_owner(e)` and
`@Predicted` dispatch read it. Ownership gates who may write `@Sync(to: owner)`
fields and who runs `@Predicted` handlers.
### 6.4 RPCs are directional remote events
RPCs are the event bus with a direction flag — no new concept:
```ludic
# doc-check: skip
@ToServer event Fire { dir: int } # client → server (a request)
@ToClients event Boom { x: int, y: int } # server → clients (a broadcast)
@Server @On(Fire) handler DoFire { spawn Bullet { dir: Fire.dir } } # authority handles the request
@On(Boom) handler Vfx { spawn Explosion { x: Boom.x, y: Boom.y } } # every client reacts
```
`@ToServer`/`@ToClients` mark an `event` remote; the compiler serializes its POD
payload (already flat — [EVENTS-DESIGN EV0](EVENTS-DESIGN.md)) and routes it through
the transport seam in the declared direction, re-`emit`ting it on the far side into
the ordinary event dispatch.
### 6.5 The whole game, high-level
```ludic
# doc-check: skip — read top to bottom: you always know where each line runs
program Shooter {
@Sync property Position { x: int, y: int }
property Health { @Sync hp: int, max: int }
@Owned model Player { @Sync Position; @Sync Health }
model Bullet { Position }
handler Physics phase FixedUpdate { … } # no tag → shared, identical everywhere
@Predicted handler Move phase Input { … } # owner predicts, server authoritative
@Server handler Death phase Update { … } # authority only; clients get the result via @Sync
@ToServer event Fire { dir: int }
@Server @On(Fire) handler DoFire { spawn Bullet { … } }
}
```
No `is_server()`, no `net_send`, no serializer — yet every line's role is legible,
and every replicated field is explicitly opted in.
---
## 7. Lowering summary
Everything above reduces to the §5 primitives, gated so an un-networked build is
unchanged:
| High-level | Lowers to |
|---|---|
| `@Sync` field / `@Sync C` in a model | a per-model `serialize_<M>` / `apply_<M>` over the replicable-and-participating fields, + a `sync manifest` a runtime reads |
| `@Sync(to: owner)` | a field tag in the manifest; the serializer branches on `owner(e) == peer` |
| `@Owned` | an `@L_owner` array + `owner()`/`set_owner()`, like `@L_kind` |
| `@Server` / `@Predicted` handler | the handler's dispatch wrapped in a role guard the runtime's role register drives (the `rt_*` seam pattern) |
| `@ToServer` / `@ToClients event` | payload serialize + `net_send(direction, …)` at the `emit` site; inbound bytes rebuild + re-`emit` |
| `world_save`/`world_load` to buffer | the existing `save()`/`load()` snapshot machinery, retargeted from a file handle to a memory buffer |
| drivable `tick_fixed`/`tick_render` | the phase runners the compiler already generates for the frame loop, exposed as callables when a game owns its `entry` loop |
No heap, no hidden runtime beyond the honestly-named transport/role seams a
networking library fills — the same relationship windowing already has.
---
## 8. The offline dividend
Because these are **opt-in-cost annotations** — serializers *generated*, nothing
*run* until a networking runtime is spliced — a build with no runtime is
**byte-identical to single-player**, and every role guard collapses to "run here."
You build the game offline, drop in a runtime, and the same annotated code starts
replicating. That is Unity's "offline mode adjustable," achieved by the same
opt-in-cost invariant the whole event system already holds.
---
## 9. The one genuinely hard corner
Determinism holds beautifully for `int`/`fixed` simulations, which makes lockstep
and rollback cheap. It **breaks for `f32` across platforms** — so **3D/voxel +
lockstep stays the hard corner** (3D wants floats; the Luanti analysis flagged that
`fixed` saturates at ±32768). No language sleight-of-hand fixes this; the
determinism contract (§11) states it plainly, and a developer choosing lockstep for
a 3D game has to accept it (or choose state replication, §10's other branch, where
per-frame determinism is not required).
---
## 10. Two model families, both reachable — neither built in
The language commits to **neither**; both are library policy over the §5 primitives.
- **Deterministic lockstep / rollback** — exchange only inputs; reproduce the sim;
on misprediction, `world_load` a snapshot and re-`tick_fixed`. Plays to Ludic's
determinism, and GGPO-cheap because snapshot/restore already exists. Best for
2D/integer/fixed games.
- **State replication** — the authority `world_save`s (or per-`@Sync` serializes),
delta-encodes against the last acked snapshot per peer, ships the diff; peers
`apply_*` it and interpolate/predict. Heavier, but needed when the sim can't be
deterministic (float physics, 3D).
A **blessed reference runtime** (§13, N6) can ship one of these so `@Sync` games
work out of the box — the way [`tests/mod_c/mod.c`](tests/mod_c/mod.c) proved the
event ABI — while the seams stay open for others.
---
## 11. The determinism contract (what the language must guarantee)
For a developer to *trust* lockstep, the language must promise, document, and where
possible *enforce*:
1. **`fixed`/`int` math is bit-identical across platforms.** The networked sim must
avoid `f32` (footgun 2). *(Enforcement: at least a documented rule; ideally a
`@Sync`/`@Server`-reachable-code float lint.)*
2. **ECS iteration order is stable** — query order is declaration/id order, and
EV6 already fixes event-dispatch order. No hash-map iteration in the sim path.
3. **RNG is deterministic from a shared seed** — `seed()` exists; the seed must be
synchronized at session start (library policy) and never re-seeded from
wall-clock mid-sim.
4. **Networked components are POD scalars** — no `ptr`/heap fields cross the wire
(footgun 3). *Enforced:* `@Sync` on a non-scalar field is a compile error.
5. **Entity ids agree across peers** — lockstep gets this free from determinism;
replication needs an id-mapping table (library policy).
This contract is the language's real networking responsibility. Most of it is
*already true*; the work is stating and enforcing it, not inventing it.
---
## 12. Design principles
1. **Mechanism in the language, policy in the library.** Expose serializers,
transport seam, ownership, snapshot, drivable sim. Never bake in authority,
prediction, or matchmaking.
2. **Role is declared, not branched.** `@Server`/`@Predicted` on handlers; unmarked
code runs everywhere. No `is_server()` in ordinary gameplay.
3. **Replication is explicit and opt-in.** Only `@Sync`-marked fields cross the
wire; participation is decided per model. Nothing replicates by surprise.
4. **Opt-in cost.** Un-networked builds are byte-identical; the sim runs offline
with the same code.
5. **Determinism is a promise the language keeps.** Enforce the POD-scalar rule;
document the float/iteration/seed rules; keep the sim reproducible.
6. **Two altitudes, always.** The high-level lowers to primitives that stay
callable. The sugar is the default; the freedom layer is never removed.
7. **Reuse, don't reinvent.** Snapshot = generalized `save()`; RPC = directional
`event`; serializer = generated from the EV2 schema; role seam = the `rt_*`
pattern. Networking is the fourth lens, not a parallel stack.
---
## 13. Suggested implementation order
Each phase is independently shippable and testable, matching how the repo phases
work (and how EVENTS-DESIGN sequenced EV0–EV7).
- **N0 — transport seam + loopback. ✅ SHIPPED.** The `net_send`/`net_poll` extern
seam and a loopback host stub; an echo test. The floor; needed almost no compiler
work — just finishing `extern fn`: a call lowers to a direct `@<sym>` call and the
header emits a matching `declare`, so any C/Rust/Zig library (a socket, here the
loopback) binds through the same seam windowing uses. `find_extern` (emit_core),
the extern branch in emit_expr's call path, `emit_extern_decls` (emit_head).
([`examples/networking/net_echo.ludic`](examples/networking/net_echo.ludic),
[`tests/net_c/loopback.c`](tests/net_c/loopback.c) → `4 10 20 30 42`.)
- **N1 — snapshot-to-buffer. ✅ SHIPPED.** Generalized `save()`/`load()` to a memory
buffer: `world_size()` (exact snapshot bytes), `world_save(buf) -> int`,
`world_load(buf, len)`. The same fixed block list (entity count, freelist, alive,
kind, vars, per-component `@S_`/`@H_`) now feeds a file (fwrite/fread) *or* a buffer
(memcpy over a threaded i64 offset), chosen by `g_snap_mode` in emit_save.ludic;
no rt_ hook (the ECS world only). The rollback/replication substrate.
([`examples/networking/net_snapshot.ludic`](examples/networking/net_snapshot.ludic),
[`tests/net_c/snapshot_mod.c`](tests/net_c/snapshot_mod.c) → `50 7 50`.)
- **N2 — `@Sync` codegen. ✅ SHIPPED.** The three-level annotations → generated
per-model `serialize_<M>`/`apply_<M>` + by-kind dispatchers (`ludic_serialize`/
`apply`/`sync_size`, and the `serialize`/`apply`/`sync_size` builtins); the
POD-scalar compile error and the empty-participation warning. The declarative
core. ([`examples/networking/net_sync.ludic`](examples/networking/net_sync.ludic) → `12 3 4 50 999`,
emit in [`selfhost/emit_net.ludic`](selfhost/emit_net.ludic).)
- **N3 — ownership. ✅ SHIPPED.** `@Owned` + the `@L_owner_arr` array +
`owner()`/`set_owner()`/`is_owner()`; owners are part of the world snapshot.
([`examples/networking/net_owner.ludic`](examples/networking/net_owner.ludic) → `-1 7 0 1`.)
- **N4 — remote events (RPCs). ✅ SHIPPED.** `@ToServer`/`@ToClients` on `event`s →
payload serialize (`[event id][fields]`) + directional `net_send` + `net_pump()`
far-side re-`emit`. ([`examples/networking/net_rpc.ludic`](examples/networking/net_rpc.ludic) → `0 8`.)
- **N5 — roles + drivable sim. ✅ SHIPPED.** `@Server`/`@Predicted` role-guarded
dispatch driven by the `@L_role` register (`set_role`/`is_server`/`local_id`);
the opt-in `entry`-owns-the-loop with `tick_fixed()`/`tick_render()`. Together
these let prediction/rollback be written in developer/library code.
([`examples/networking/net_roles.ludic`](examples/networking/net_roles.ludic) → `1 102`.)
- **N6 — a blessed reference netcode runtime. ✅ SHIPPED.** A Ludic library
([`examples/networking/net_rt.ludic`](examples/networking/net_rt.ludic)) — server-authoritative state
replication over the primitives — plus a full end-to-end demo, proving the seams
the way the C mod proved the event ABI, but in pure Ludic over the built-in
transport. Library policy, swappable for lockstep+rollback.
([`examples/networking/net_demo.ludic`](examples/networking/net_demo.ludic) → `5 999 5`.) A built-in
loopback transport (N0) means all of this needs **no foreign code at all**.
N0–N2 deliver "state can be declared, serialized, and moved." N3–N4 add ownership
and RPCs. N5 unlocks prediction. N6 is a batteries-included default that others can
replace. The **determinism contract (§11)** is cross-cutting — documented from N0,
enforced incrementally.
---
## 14. Open decisions
1. **Field direction vocabulary.** `@Sync(to: owner)` / `@Sync(to: all)` confirmed
in spirit; is `to:` the right key, and do we also want `to: server` (a field only
the authority reads)? How does per-field direction interact with `@Predicted`?
2. **Blessed runtime, or seams only?** Events chose "seams + reference mod, bless
nothing." Networking's DX may justify shipping one reference runtime (N6). One,
or none?
3. **Authority default.** Server-authoritative with `@Predicted` opt-in is the safe,
Unity-ish default. Confirm, or keep the language authority-neutral and leave even
that to the runtime?
4. **Drivable loop shape.** Whole-frame `tick()` vs the `tick_fixed()`/`tick_render()`
split; how a developer-owned `entry` loop coexists with scenes, the `rt_*` hooks,
and the auto-loop (opt-in via presence of an `entry` block?).
5. **Snapshot granularity.** Full `world_save` vs per-`@Sync` serialize vs a
generated delta between two snapshots — which does the language provide, and which
is library work?
6. **Float determinism enforcement.** A documented rule only, or a real lint that
flags `f32` reachable from `@Server`/`@Predicted`/`@Sync` code paths?
7. **Ownership at component granularity.** Unity's DOTS allows per-component owner
send-rules. Is `@Owned` per-*entity* enough, or do we need per-component owners
(a real complexity jump)?
8. **Networking substrate for the remote half of EVENTS EV7.** This doc's directional
remote events (N4) *are* the local/remote split EVENTS-DESIGN EV7 deferred for
"no networking substrate." N4 is that substrate — the two docs meet here.
---
*Companion to [EVENTS-DESIGN.md](EVENTS-DESIGN.md) (remote events are directional
events; serializers reuse the EV2 world-table schema; EV7's deferred local/remote
split lands here as N4), [LIFECYCLE-DESIGN.md](LIFECYCLE-DESIGN.md) (`@OnChange`/LC2
is the dirty-tracking primitive for delta replication), and
[SCENES-DESIGN.md](SCENES-DESIGN.md). Supersedes nothing until the compiler work in
§13 lands.*

View file

@ -81,10 +81,17 @@ bin/x help # every command
| [`docs/`](docs/) | the per-symbol API reference, regenerated into the docs site. |
| [`COMPILING.md`](COMPILING.md) | the native pipeline: `ludicc → LLVM IR → exe`, the `rt_*` runtime protocol, and the (pending) wasm/cross-compile/shared-library paths. |
Design and roadmap documents — `LANGUAGE.md`, `EVENTS-DESIGN.md`,
`NETWORKING-DESIGN.md`, `SCENES-DESIGN.md`, `LIFECYCLE-DESIGN.md`,
`SYNTAX-REDESIGN.md`, `MOBILE-DESIGN.md`, `LUANTI-ROADMAP.md`, `BOOTSTRAP.md` —
live at the repository root today and are being migrated to the wiki.
The design and roadmap material lives on the **[wiki](https://git.workshopsoft.io/workshopsoft/ludic/wiki)**:
the [Events](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Events),
[Networking](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Networking),
[Scenes](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Scenes),
[Lifecycle](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Lifecycle),
[Mobile](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Mobile) and
[Syntax-redesign](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Syntax-Redesign)
design records, the [Bootstrap deep-dive](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Bootstrap),
and the [Luanti roadmap](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Roadmap/Luanti).
The root keeps only this README plus the two user-facing references,
[`LANGUAGE.md`](LANGUAGE.md) and [`COMPILING.md`](COMPILING.md).
## Language at a glance

View file

@ -1,354 +0,0 @@
# Scenes, expanded — a design doc
> **Status: S0 shipped; S1–S6 are design.** The base construct — `scene` /
> `layer` / `on enter` / `on exit` / `become`, lowered to the implicit machine of
> §3 and §9 — is implemented and tested ([`examples/lang/scenes.ludic`](examples/lang/scenes.ludic),
> a `bin/x test` check). The extensions in §4–§8 (scene-owned entities, richer
> layers, the overlay stack, scene-local state, transition parameters) are still
> design targets. This document reaches deliberately past the thin sketch so we
> can decide the shape before building each one. §11 lists the open decisions.
---
## 1. Where we are
A Ludic program is almost always several mutually-exclusive states — a title
screen, the overworld, a battle, a pause menu. Two ways to write that exist in
the language today, and a third is sketched:
| Approach | Status | Cost |
|---|---|---|
| Mode register consulted at the top of every handler (`if reg(R_MODE) == …`) | works | a guard re-read per handler per frame; state is a magic number; nothing scopes to it |
| `machine`/`state`/`become` over a register | works | dispatch on the register each frame; still one flat register, no per-state handlers or lifecycle |
| `scene`/`layer`/`on enter`/`on exit` | **sketch only** | — |
The sketch ([`examples/lang/scenes.ludic`](examples/lang/scenes.ludic)) specs:
- Exactly **one scene active**; the `start` scene runs first.
- A scene's handlers run only while it is active; handlers outside any scene are
global.
- **Layers group handlers; declaration order is draw order** — within a phase,
globals first, then the active scene's layers in written order.
- `on enter` / `on exit` are lifecycle hooks (not phases).
- `become Name` runs the old scene's `on exit`, switches, runs the new `on enter`
— two direct calls and a store, no dispatch table.
That's a good spine. The problem is it's specced as **sugar over a mode
register**: it tidies the syntax but adds little the register didn't already
have. The compiler knows *much* more at a scene boundary than a register does,
and this doc is about spending that knowledge.
---
## 2. Design principles
1. **The scene boundary is a compile-time fact — use it.** The set of handlers,
layers, and owned state for each scene is known statically. Transitions should
be direct calls and a single store, never a table walk. (The sketch already
promises this; the extensions must preserve it.)
2. **Structure, not registers.** Anything you'd track with a hand-managed
register alongside the mode — which entities belong to this state, which layers
are drawn, what's paused — should be expressible *as* scene structure and
enforced by the compiler.
3. **Reuse the machinery we already have.** Layers pausing, scenes tearing down
their entities, and hooks firing are all expressible in terms of
`enable`/`disable` (cheap flag flips), `despawn`, and the lifecycle-hook
lowering. Scenes should *compose* those, not introduce a parallel runtime.
4. **One active-scene path stays hot; overlays are the exception, not the rule.**
The common case (one full-screen scene at a time) must lower to the cheapest
possible dispatch. Richer shapes (a pause menu over a frozen world) are opt-in
and pay only for what they use.
---
## 3. Core model (firmed up from the sketch)
```ludic
# doc-check: skip — illustrative
scene Title start {
on enter { ui_open(UI_Menu) }
on exit { ui_visible(UI_Menu, 0) }
layer Main {
handler Choose phase Update {
if ui_clicked(UI_NewGame) { become Overworld }
}
}
}
scene Overworld {
on enter { spawn_party() }
layer World { handler Move phase Update { … } }
layer Hud { handler Draw phase Render { … } }
}
```
Unchanged from the sketch, made precise:
- **Scenes number themselves by declaration order**, exactly like `machine`
states — `Title` is `0`, `Overworld` is `1`. The active scene lives in one
implicit register (`__scene`). This makes `scene` a `machine` the compiler
writes for you, which is the right mental model and the right lowering.
- **A layer handler may not use phase `Start`.** `Start` runs once at boot,
before any scene is entered; scene setup goes in `on enter`.
- **Global handlers still run every frame**, before any scene's layers, in every
phase. A scene's layers run only while it is active.
Everything below is new.
---
## 4. Extension E1 — scene-owned entities (scoped lifetime)
The single biggest thing a mode register cannot do: **own the entities that only
make sense in this state, and tear them down automatically on exit.** Today a
battle scene spawns combatants in `on enter` and must remember to despawn every
one in `on exit` — miss one and it leaks into the overworld.
Proposal: entities spawned *by a scene's handlers or `on enter`* are tagged with
that scene, and `on exit` despawns them by default.
```ludic
# doc-check: skip
scene Battle {
on enter { spawn Foe; spawn Foe; spawn Foe } # tagged @Battle
# on exit: implicit `despawn all @Battle` — no manual cleanup
layer World { handler Fight phase Update { … } }
}
```
- Implemented as an implicit **scene tag** (a `{Battle}`-style kind bit) added at
`spawn` time while a scene is active, plus a generated `despawn`-by-tag in the
synthesized `on exit`. Reuses the existing tag-filter and despawn-hook
machinery — no new runtime.
- **Opt out** for entities that should outlive the scene: `spawn Foe persist` (or
spawn it from a global handler). Persisted entities keep their data across the
transition, matching how `disable` keeps field data.
- Composes with `@OnDespawn(Model)`: the destructor hook fires for each
scene-owned entity as it's torn down, so `drop_loot`-style cleanup still runs.
**Open:** does a re-`become Battle` get fresh entities (fresh tag generation) or
resume the old ones? Default: fresh. See §9.
---
## 5. Extension E2 — layers are more than draw order
The sketch uses layers only to order `Render`. Layers are the natural unit for
three more things, all built on the existing `enable`/`disable` flag flips:
1. **Per-layer toggle.** `disable Hud` / `enable Hud` flips one flag; the layer's
handlers stop running and drawing. This is `disable Handler` generalized to a
named group — same one-flag-flip cost.
2. **Pause vs. tear-down.** A layer can keep drawing while its *update* handlers
are suspended:
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
}
```
When a pause menu opens over the Overworld (see E3), `World`'s `Update`
handlers suspend but its `Render` handler still paints the frozen world behind
the menu. Today that requires a `if !paused` guard in every update handler;
with layers it's structural.
3. **Layer lifecycle hooks.** `on show` / `on hide` per layer, mirroring scene
`on enter`/`on exit`, for the toggle points. (Naming TBD — could fold into the
`@OnEnable`/`@OnDisable` annotations, which already exist for properties.)
---
## 6. Extension E3 — the scene *stack* (the headline)
The sketch says "exactly one scene is active." That's the right default and the
wrong constraint. The states a mode register handles *worst* are the ones that
**overlay without replacing**: a pause menu over live gameplay, a dialog box, an
inventory screen, a confirmation prompt. With one register you either lose the
underlying state or hand-roll a "previous mode" variable and restore it.
Proposal: keep "one *base* scene," but allow scenes to be **pushed as overlays**.
```ludic
# doc-check: skip
scene Overworld {
layer World { handler Move phase Update { … } handler Draw phase Render { … } }
layer Hud { handler DrawHud phase Render { … } }
on enter { … }
handler PauseKey phase Input { if pressed(KEY_ESC) { push Pause } }
}
scene Pause overlay { # `overlay` = pushed, not swapped
on enter { dim_backdrop() }
layer Menu {
handler Nav phase Update {
if pressed(KEY_ESC) { pop } # back to Overworld, untouched
}
handler Draw phase Render { ui_render() }
}
}
```
- `push Name` runs `Name`'s `on enter` and makes it the top scene **without**
running the base scene's `on exit`. `pop` runs the overlay's `on exit` and
returns to whatever was beneath.
- **Update belongs to the top of the stack; render walks the whole stack bottom
to top.** So `Pause`'s `Menu` layer draws over `Overworld`'s frozen `World` and
`Hud`. This is the default that makes pause menus "just work." An overlay that
should let the layer beneath keep updating opts in with `push Name passthrough`.
- **The stack is a small fixed-capacity array of scene ids** (say 8) in a
compiler-owned buffer — not heap, not a linked structure. `push`/`pop` are an
index bump and an `on enter`/`on exit` call. Depth overflow is a compile-time
or trap decision (§9).
- `become` still exists and still means "swap the base scene" (full `on exit` →
`on enter`, stack cleared). `push`/`pop` are the overlay verbs. Keeping the two
distinct is what lets the common single-scene path stay a single register.
This is the extension that turns `scene` from "nicer mode register" into
something with no clean equivalent in the register world.
---
## 7. Extension E4 — scene-local state
A scene almost always has state that exists only while it's active — a battle's
turn counter, a menu's cursor index. Today that's a global register that other
scenes could stomp. Proposal: **`var` / `const` declared inside a `scene` is
scoped to it**, storage shared across scenes that are never simultaneously active
(the compiler can overlap their storage since only one base scene runs at a
time — an arena-per-scene, or a union).
```ludic
# doc-check: skip
scene Battle {
var turn = 0 # visible only inside Battle; reset by `on enter` if desired
layer World { handler Step phase Update { turn += 1 } }
}
```
- Reads/writes lower to a fixed offset in the scene's state block, no register
indirection.
- Overlay scenes (E3) that *can* be live simultaneously with their base cannot
share storage — the compiler keeps their blocks distinct. Base scenes that
never coexist share.
---
## 8. Extension E5 — parameterized transitions, and the reserved annotations
**Parameters on transitions.** `become`/`push` can carry arguments that the
target's `on enter` binds — so a battle knows which foes, a dialog knows which
line:
```ludic
# doc-check: skip
scene Battle {
on enter (foe_kind: int, count: int) { for i in 0 .. count { spawn_foe(foe_kind) } }
}
# elsewhere:
become Battle(FOE_GOBLIN, 3)
```
Lowers to argument stores into the scene's state block (E4) immediately before
the `on enter` call. No variadic runtime; the arity is checked at compile time.
**The already-reserved annotation form.** [LANGUAGE.md:374](LANGUAGE.md:374)
reserves `@OnEnter` / `@OnExit` as handler annotations "waiting on scene support."
This doc adopts them as the annotation spelling of `on enter` / `on exit`,
mirroring how `@OnStart` is the annotation form of `phase Start`:
```ludic
# doc-check: skip
@OnEnter(Battle) handler Setup { … } # == Battle's `on enter`
@OnExit(Battle) handler Teardown { … }
```
Both spellings desugar to the same synthesized scene-lifecycle function; a scene
may use either, not both, for a given hook.
**`reads`/`writes` + scenes (forward-looking).** The `reads`/`writes` clauses are
parsed but unconsumed ([LANGUAGE.md:717](LANGUAGE.md:717)). Once an analysis pass
exists, a scene's layers declare which state they touch, and the scheduler can run
independent layers of the active scene in parallel within a phase — the scene
boundary gives the pass a natural scope to reason about. Noted as a destination,
not part of the first cut.
---
## 9. Lowering summary
Everything above reduces to existing runtime concepts:
| Construct | Lowers to |
|---|---|
| active base scene | one implicit register `__scene`, states numbered by decl order — literally a compiler-written `machine` |
| `become Name` | `on exit` call · `set __scene` · `on enter` call (two direct calls + store, as the sketch promises) |
| scene layers in a phase | the phase scheduler, after global handlers, dispatches on `__scene` to that scene's layer handlers in declaration order |
| `push`/`pop` (E3) | fixed-capacity scene-id array + index; render walks it, update reads its top |
| scene-owned entities (E1) | implicit kind tag at `spawn`; generated `despawn`-by-tag in synthesized `on exit`; reuses despawn hooks |
| layer toggle / pause (E2) | the same one-flag-flip as `disable Handler`, keyed per layer |
| scene-local `var` (E4) | fixed offsets in a per-scene state block; non-coexisting scenes share storage |
| transition args (E5) | arg stores into the state block before the `on enter` call |
| `@OnEnter`/`@OnExit` (E5) | the same synthesized lifecycle functions as `on enter`/`on exit` |
No heap, no dispatch tables, no new allocator. The active-scene path is a
register read and a static branch; the stack adds a small array only for programs
that push overlays.
---
## 10. Suggested implementation phases
Each is independently shippable and testable, matching how the repo phases work.
- **S0 — parse & lower the sketch.** ✅ **Done.** `scene`/`layer`/`on enter`/`on
exit`/`become` lowered to the implicit `machine`; the active scene is
snapshotted per phase so exactly one scene's layers dispatch in any phase.
[`examples/lang/scenes.ludic`](examples/lang/scenes.ludic) compiles, runs, and is checked
by `bin/x test`. This is the floor everything else builds on.
- **S1 — `@OnEnter`/`@OnExit` annotation form** (E5, cheap once S0 exists).
- **S2 — layer toggle & pause** (E2) on top of the existing `enable`/`disable`.
✅ *Toggle shipped* (via EVENTS-DESIGN EV1 layers): `enable layer L` / `disable
layer L` flips an `@LE_<L>` flag that gates the layer's handlers (emitted only
for toggled layers, so untouched scene programs stay byte-identical), and a
`public` layer fires `layer_<L>_show`/`_hide` — see
[`examples/events/layer_events.ludic`](examples/events/layer_events.ludic). Still open: the
*pause* half (keep drawing while `Update` handlers suspend) and `on show`/`on
hide` blocks.
- **S3 — the scene stack** (E3): `push`/`pop`/`overlay`/`passthrough`. The big one.
- **S4 — scene-owned entities** (E1) and **scene-local state** (E4).
- **S5 — transition parameters** (E5).
- **S6 (later) — `reads`/`writes` scheduling** (E5), gated on the analysis pass.
S0–S1 deliver the sketch as promised; S2–S3 are where the "great potential"
actually lands; S4–S5 are ergonomics; S6 is a performance destination.
---
## 11. Open decisions
1. **Re-entering a scene:** fresh entities/state, or resume? (Default proposed:
`become` = fresh, `push`/`pop` = the pushed scene is fresh each push, the base
underneath is untouched.)
2. **Stack depth:** compile-time cap with an error on overflow, or a runtime trap?
What capacity (8? configurable)?
3. **`passthrough` granularity:** does a passthrough overlay let *all* lower
layers update, or can it name which phases fall through?
4. **Layer hook naming:** `on show`/`on hide`, or reuse `@OnEnable`/`@OnDisable`?
5. **Scene-local storage sharing:** union non-coexisting scenes automatically, or
require an explicit opt-in so the sharing is visible in source?
6. **Global handlers and overlays:** do globals run once per frame regardless of
stack depth (proposed: yes), or per active scene?
7. **`become` from inside an overlay:** does it clear the stack (proposed: yes) or
is it an error while overlays are pushed?
---
*Companion to [LANGUAGE.md §"Scenes & layers"](LANGUAGE.md) and the ordering
sketch in [`examples/lang/scenes.ludic`](examples/lang/scenes.ludic). Supersedes nothing
until the compiler work in §10 lands.*

View file

@ -1,375 +0,0 @@
# Ludic Syntax Redesign — Cohesion Pass
A plan to make Ludic's syntax internally consistent. It fixes the drift between
the spec and the compiler, then unifies the grammar around two rules. Scope:
**full redesign (Phases 0–5)**. Named-field direction: **colon everywhere**.
> Status: **Phases 1–5 complete.** Every phase kept the compiler self-hosting to
> a fixpoint (`bin/x test` 14/14), and each syntax migration was proven
> behaviour-preserving (the migrated compiler compiles itself to byte-identical
> IR; every golden game renders byte-identically). Landed on branch
> `syntax-redesign-phase2` over a committed baseline on `main`.
>
> Coordinated with the toolchain agent (CLI front-end / `ludicc`+`ludic`
> binaries) via serialized reseeds of `selfhost/ludicc.seed.ll`; Phase 1 rode in
> alongside their `emit_*`/`main.ludic` work, combined suite **14/14 green**.
---
## Why (the findings)
Verified against the self-hosted compiler ([selfhost/parse.ludic](selfhost/parse.ludic),
[selfhost/parse_game.ludic](selfhost/parse_game.ludic), [selfhost/lex.ludic](selfhost/lex.ludic)):
**Structural incoherence**
1. **Five micro-syntaxes for named parts** — `name: type = d` (fields), `name: type`
(params), `Field = { k = v }` (spawn), `[Name, {Tag}]` (query), whitespace
`phase X reads [..]` (system clauses), `key=value` (ui props).
2. **`=` means seven things, `:` means one** — assignment, default, record init,
ui prop, extern symbol, const value, `state X = N` all use `=`.
3. **No statement terminators** — `\n` and `;` both lex to `TK_NL`
([lex.ludic:45,121](selfhost/lex.ludic)) but the parser never requires a
separator, so `t.kind = k t.text = x t.ival = v` (three statements, spaces
only) is idiomatic.
**Broken / dead syntax (compiler-verified)**
4. `edge system` — **hard parse error** (documented at [LANGUAGE.md:188](LANGUAGE.md)). *(✅ fixed in Phase 1)*
5. `pure fn` — parses, `pure` silently discarded ([parse.ludic:272](selfhost/parse.ludic)); undocumented. *(✅ Phase 3d: now `@pure`)*
6. `@anno` + `reads/writes/needs/uses [..]` — parsed then thrown away
([parse_game.ludic:15-31](selfhost/parse_game.ludic)); four synonyms, two undocumented. *(✅ Phase 3c: `needs`/`uses` dropped)*
7. `scene`/`layer`/`on enter` — full LANGUAGE.md section + [examples/lang/scenes.ludic](examples/lang/scenes.ludic),
**does not compile** (`expected declaration`).
8. `query (v) [..]` in a system signature — two LANGUAGE.md sections +
[examples/lang/qdecl.ludic](examples/lang/qdecl.ludic), **does not compile** (`parse error: {`). *(✅ implemented in Phase 1)*
9. `when cond {}` — documented ([LANGUAGE.md:329](LANGUAGE.md)) + in all three editor
highlighters, **never parsed**. *(✅ implemented in Phase 1 as an if-without-else alias)*
10. CLI `--emit-llvm`/`-o`/`--shared`/`--fmt` — documented, but `ludicc` only
accepts `--windowed`/`--headless` ([main.ludic:8-14](selfhost/main.ludic)).
**Philosophical splits**
11. Operators are words (`and`/`or`/`not`), symbols (`==`/`<=`), *and* functions
(`band`/`shl`) at once.
12. Three overlapping control families — `if`/`when`, `match`, `machine`/`become`
— and `enter` reuses `become`'s AST node ([parse.ludic:176-177](selfhost/parse.ludic)). *(Phase 4: `if`/`when` kept by choice; magic-int dispatch resolved)*
13. Typed components/structs exist, but real state lives in 64 untyped int
registers (`reg`/`set_reg`), so `machine`/`match` dispatch on magic numbers. *(✅ Phase 4: auto-numbered states + `enum` name the values)*
---
## The two rules everything converges on
**Rule A — `:` associates, `=` binds.**
- `:` introduces a *named part* and its type or value in a declarative structure:
component/struct fields' types, record initializers, ui props, (future) named
call arguments.
- `=` binds a value to a storage location or a constant: `let`, assignment
(`+=` …), `const` value, a field's **default**, and the extern symbol.
- A field declaration uses both, unambiguously: `x: int = 0` reads "`x` *has type*
`int` (`:`), *defaulting to* `0` (`=`)" — same shape as Rust/TypeScript.
- A record/spawn initializer is declarative, so it uses `:` — `Pos { x: 10 }`.
**Rule B — a statement ends at a newline (or `;` or `}`).**
- Newlines become significant. Two statements on one line require an explicit
`;`. `ludic-fmt` normalizes one statement per line and inserts/removes `;`.
Everything below is these two rules applied construct by construct.
---
## Target grammar (before → after)
### Records / spawn initializers
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
spawn Hero { Pos = { x = 10, y = 5 } Player = { } }
# after
spawn Hero {
Pos { x: 10, y: 5 }
Player {}
}
```
`Field = { k = v }` → `Field { k: v }`. The component name is followed directly
by a record; fields use `:`. (Record literals elsewhere read the same:
`{ x: 10, y: 5 }`.)
### UI props → named-argument form
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
panel id=Root w=288 pad=16 gap=6 align=center { label text="HI" size=26 }
# after
panel(id: Root, w: 288, pad: 16, gap: 6, align: center) {
label(text: "HI", size: 26)
}
```
A widget becomes "a constructor with named args, then an optional child block."
This deletes the bespoke `key=value` dialect and reuses `:` + commas. (Lower-churn
alternative if the paren form is disliked: keep whitespace separation but colonize
— `panel id: Root w: 288` — still removes the `=` overload.)
### System clauses & modifiers → one annotation channel
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before
edge handler Move @deterministic reads [Vel] writes [Pos] phase FixedUpdate
query (p, v) [Pos, Vel] where a.x > 0 { … }
# after
@edge @deterministic
handler Move
phase FixedUpdate
reads [Vel] writes [Pos]
query (p, v) [Pos, Vel] where p.x > 0
{ … }
```
- Prefix modifier words (`edge`, `pure`, `export`) are **retired**; all modifiers
become `@annotations`, parsed into a real list on the node (not skipped). This
fixes the `edge system` parse bug (#4) by construction.
- `needs`/`uses` are dropped; `reads`/`writes` stay as the two structural clauses
and are **stored** (even if analysis is future work) rather than discarded.
- The `query (v) [..]` signature clause is **actually implemented** in
`parse_system` (#8), lowering to the same `S_QUERY` node as the inline `for`.
### extern
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
extern function c_hypot(a: fixed, b: fixed) -> fixed = "hypot_fx" # unchanged
```
The `= "symbol"` is a binding under Rule A — it stays.
### Statements
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before (legal today)
t.kind = kind t.text = text t.ival = ival
# after
t.kind = kind
t.text = text
t.ival = ival
# or, explicitly, on one line:
t.kind = kind; t.text = text; t.ival = ival
```
### Control flow (Phase 4)
- **`when` vs `if`** — `when` is now a working `if`-without-else alias (Phase 1).
Phase 4 decides whether to keep both spellings or collapse to one; if collapsed,
remove `when` from docs, the parser, and all editor highlighters together.
- **Typed states replace magic-int machines.** Introduce `enum`, and let
`machine` dispatch on a typed variable instead of a register:
```ludic
# doc-check: skip — illustrative redesign snippet (proposed / partial syntax)
# before # after
const R_PHASE: int = 0 enum Phase { KnightMenu, KnightResolve, MageMenu, EnemyTurn }
machine R_PHASE { var phase: Phase = Phase.KnightMenu
state KnightMenu = 0 { … become … } machine phase {
state KnightResolve = 1 { … } state KnightMenu { … become KnightResolve }
} state KnightResolve { … }
}
```
`state X = N` loses the magic `= N` (ordinal comes from the enum). `become`
and `enter` (scenes) keep one shared lowering but read from a typed slot.
---
## Phase sequence
Each phase is independently shippable and ends green on `bin/x test` +
`bin/x selfhost-test` (fixpoint).
### Phase 0 — Doctrine (done here)
Rules A and B above; colon-everywhere; `@`-annotations as the single modifier
channel; typed enums for state. No code.
### Phase 1 — Truth-in-documentation ✅ DONE
Made spec ⇄ compiler agree **before** any grammar change. What landed:
- ✅ **`edge system` crash fixed** (#4) — `parse_system` now consumes an optional
`edge` marker before `system` ([parse_game.ludic](selfhost/parse_game.ludic)).
(`edge` is a pure marker; the emitter never lowered it differently.)
- ✅ **Signature-`query` implemented** (#8) — `query (vars) [terms] where c` in a
system header desugars to the same `S_QUERY` node the inline `for` builds, so
`examples/lang/qdecl.ludic` compiles and runs. Also fixed multi-line clause parsing
(clauses may now span lines).
- ✅ **`when c { }` implemented** (#9) — as an `if`-without-else alias in
[parse.ludic](selfhost/parse.ludic). Docs + editors already listed it; now the
compiler agrees, so no editor-vocab churn was needed.
- ✅ **`scene`/`layer` marked not-yet-implemented** (#7) — prominent note in
LANGUAGE.md §"Scenes & layers" + a header on [examples/lang/scenes.ludic](examples/lang/scenes.ludic).
Full scene front-end + emission deferred (real work, out of Phase 1 scope).
- ✅ **`reads`/`writes` honesty** (#6) + the stale "Not yet implemented" section
updated in [LANGUAGE.md](LANGUAGE.md); scenes/reads-writes/dropped-CLI-flags now
listed there.
- ✅ **`bin/x test` guards drift** — added a `qsmoke qdecl` compile check. Suite
green (14/14 incl. the toolchain agent's CLI smoke tests).
- ✅ **CLI flags** (#10) — `--shared`/`--fmt`/wasm noted as dropped-with-the-C-driver
in LANGUAGE.md; `-o`/`--emit-llvm` were being re-added by the toolchain agent
(now real, verified in `bin/x test`); COMPILING.md updated by that agent.
- Deferred (intentionally): `pure`-is-ignored (#5) is undocumented and harmless;
it will be folded into `@pure` in Phase 3 rather than churned now.
- **Not done / by design:** `scenes.ludic` is *not* added to `bin/x test` (it can't
compile yet — a positive test would fail; the header note + LANGUAGE.md warning
cover the drift instead).
### Phase 2 — Statement separation (Rule B) ✅ DONE
Landed on branch `syntax-redesign-phase2` (baseline committed on `main` first).
- ✅ **Parser enforces a separator** — `block()` requires a newline or `;` after
each statement, else `expected newline or ';' between statements`
([parse.ludic](selfhost/parse.ludic)). Also fixed `if`-without-`else` swallowing
its trailing separator (it now peeks for `else` and restores if absent).
- ✅ **Interpretation chosen:** *require a separator*, not *reflow to one-per-line*.
The migration **inserts `;` at statement boundaries** and leaves lines intact —
comment-safe, minimal-diff, and it makes boundaries visible without an
opinionated reflow. One-per-line stays the recommended hand-written form.
- ✅ **Migration tool** ([tools/ludic-tools/migrate_separators.c](tools/ludic-tools/migrate_separators.c),
reuses the toolchain lexer) with a
**verification oracle**: a `;` inserted at a real boundary is a semantic no-op,
proven by the migrated compiler compiling itself to **IR byte-identical to the
seed** and every golden game rendering identically. ~1100 boundaries across the
corpus (examples, runtime, and the 25 self-host fragments).
- ✅ **Reseeded** to the strict compiler (19557 lines); C-free bootstrap fixpoint
holds; `bin/x test` 14/14; all goldens byte-identical; qdecl runs correctly.
- ✅ **Docs updated** — Rule B documented in LANGUAGE.md §Statements; BOOTSTRAP.md
R1 (which advertised no-separator juxtaposition as legal) and its stale code
fences updated; `check-docs` (now a live strict parse gate) green across all docs.
**Bug found & fixed en route:** a multi-line string literal in
[emit_expr.ludic](selfhost/emit_expr.ludic) (`emit(")<newline>")`) lexed fine in
the self-host lexer but the **C toolchain lexer** (`ludic_syntax.h`, shared by
sepfix, `ludic-fmt`, and the LSP) stops strings at newline — so it mis-lexed and
`ludic-fmt` would corrupt such a file. Converted it to the byte-identical `\n`
escape. **Open follow-up:** align the C lexer to allow newlines in strings, or
forbid literal newlines in string literals language-wide (the two lexers should
agree). Flagged to the toolchain owners.
### Phase 3 — Named-field unification (Rule A)
**3a — spawn/record initializers ✅ DONE.** `Comp = { f = v }` → `Comp { f: v }`.
`record()` requires `:` and `parse_spawn()` drops the `=` before the record
([parse.ludic](selfhost/parse.ludic), [parse_game.ludic](selfhost/parse_game.ludic)).
The `=` is now assignment/const/default/extern-binding only. Migration tool:
[migrate_records.c](tools/ludic-tools/migrate_records.c) (spawn-context aware).
Records live only in games, so the seed was unaffected; verified every golden
byte-identical, old `=` form now rejected, reseeded, `bin/x test` 14/14. Doc examples
updated (LANGUAGE.md, BOOTSTRAP.md R2).
**3b — ui props → `key: value` ✅ DONE.** `panel id=Root w=288` → `panel id: Root
w: 288`. `parse_widget` now reads props with `:` ([parse_game.ludic](selfhost/parse_game.ludic)).
Chose the **colonized** form over parenthesized named-args: it satisfies Rule A
(the `=` overload is gone) with minimal churn, needs no new grammar, and `emit_ui`
(which reads the AST) and `ludic-fmt` (which formats `:` correctly by default)
were both untouched. Migration: [migrate_ui.c](tools/ludic-tools/migrate_ui.c).
menu golden byte-identical, old `=` form rejected, reseeded, `bin/x test` 14/14.
(The parenthesized form `panel(id: Root, w: 288)` remains a possible future
refinement if the language ever gains named call arguments.)
**3c — dropped the dead `needs`/`uses` clause synonyms ✅ DONE.** `reads`/`writes`
stay (documented; still parsed-and-reserved). `needs`/`uses` were undocumented and
unused anywhere in the corpus — removed from `parse_system`. *Not done:* actually
*storing* reads/writes on the node for an analysis pass — that's analysis
infrastructure, out of scope for a syntax pass.
**3d — modifiers → `@`-annotations ✅ DONE.** `edge`/`pure`/`export` prefix keywords
are retired; declaration modifiers are now leading `@annotations`: `@export fn`,
`@edge system`, `@pure`, `@deterministic`. `parse_one_decl` collects a leading
`@anno` run and `@export` sets the fn export flag ([parse.ludic](selfhost/parse.ludic));
the dead `edge`-dispatch was removed from `parse_system`. Migrated the one
`@export` user ([examples/library/combat.ludic](examples/library/combat.ludic)); old
prefix forms now rejected. Behavior-identical: the export flag is parse-only in
the self-hosted emitter (it emits `@fn_<name>` for every function and never reads
the flag — the C-ABI-export capability is vestigial, a pre-existing gap), so
`@export` and the old `export` produce byte-identical IR. Reseeded, fixpoint
holds, `bin/x test` 14/14, goldens byte-identical.
**Phase 3 is complete.** The `=`/`:` overload (finding #2) and the modifier-zoo
(findings #5, #6) are resolved; `:` associates and `=` binds throughout.
Each sub-phase follows the proven pattern: parser change → verification-gated
migration (IR byte-identical / goldens identical) → reseed → docs. The migration
tools ([migrate_separators.c](tools/ludic-tools/migrate_separators.c),
[migrate_records.c](tools/ludic-tools/migrate_records.c)) are the reusable spine.
### Phase 4 — Control-flow & state consolidation
**4a — machine states auto-number ✅ DONE.** `state KnightMenu = 0 { }` →
`state KnightMenu { }`; a state's value is its declaration index (an explicit
`= expr` still works). Removes the magic constants from state machines
([parse.ludic](selfhost/parse.ludic)). combat.ludic migrated; chronorift golden
byte-identical.
**4b — `enum` types ✅ DONE.** `enum Action { Attack, Guard, Item, Flee }` declares
named `int` constants; a variant is a compile-time int accessed as `Action.Guard`
(= 1), numbered by order. Parser `parse_enum` + dispatch, `enum_ordinal` resolver
in [emit_core.ludic](selfhost/emit_core.ludic), and `Enum.Variant` handling in
[emit_expr.ludic](selfhost/emit_expr.ludic). combat.ludic's battle menus now
dispatch on `KnightAct`/`MageAct` instead of `0..3`; chronorift golden
byte-identical. Editor vocab (`ludic_syntax.h`, JetBrains, TextMate, emacs) gained
`enum` and lost the retired `edge`/`export`/`pure` decl keywords; check-vocabulary
+ test-tools green. **Scoped:** enums are a naming layer over `int` (no distinct
runtime type / enum-typed variables yet) — that keeps register/save semantics
untouched, which the "enum var replaces the register" vision would have to solve.
**`when` vs `if` — kept both (decision).** `when` stays as the `if`-without-else
spelling: it is not incoherent so much as a readability signal ("no else here"),
it is documented and highlighted, and it is a pure alias with no semantic overlap
to untangle. The real target of finding #12 — dispatch on magic integers — is
addressed by 4a/4b, not by collapsing `if`/`when`.
**Bitwise operators — kept as functions (decision).** `band`/`bor`/`bxor`/`bnot`/
`shl`/`shr` stay functions, documented as the deliberate "one spelling, symbols
stay free" choice (LANGUAGE.md §Expressions already states this). Promoting them
to operators would re-introduce the symbol soup the current design avoids.
### Phase 5 — Vocabulary anchored to the compiler ✅ DONE
The editor vocabulary already stayed in sync *with itself* (`check-vocabulary.py`
compares `ludic_syntax.h`, the JetBrains lexer, and the TextMate grammar). The
missing anchor was the **compiler**: a keyword could be highlighted everywhere
and still be silently unparsed. Closed both loops:
- ✅ **Vocabulary ⇄ parser.** `check-vocabulary.py` now extracts every keyword
`selfhost/parse*.ludic` dispatches on (`is_id(...)` / `streq(t.text, ...)`) and
requires the header's declaration + clause keywords to be a subset — with a
`LUDIC_KW_RESERVED` escape hatch for documented, not-yet-implemented keywords
(`scene`/`layer`/`on`/`start`), itself checked so a reserved word that gets
implemented must be promoted. Verified it catches an injected bogus keyword.
- ✅ **Reconciled the drift it exposed.** Removed the highlighted-but-unparsed
`scene`/`layer`/`on`/`start` (→ RESERVED) and the never-implemented
`needs`/`uses`/`requires`/`ensures`/`invariant`/`effects` clause words, and the
retired `edge`/`export`/`pure` prefix modifiers, from `ludic_syntax.h`, the
JetBrains lexer, the TextMate grammar, and the emacs mode; added `enum`/`main`.
`@`-annotations already highlight generically (`@[A-Za-z_]…`). test-tools 28/0.
- ✅ **Doc-fence compilation** — the other half of "single source of truth" — was
already live: `check-docs.py` compiles every ` ```ludic ` fence through the
self-hosted `ludicc --fmt` parse gate (revived during Phase 1's coordination).
Full generation-from-one-list (emit the editor files from a manifest) was not
needed: the bidirectional *checks* give the same guarantee — nothing can drift
without CI failing — without a code-generation step to maintain.
**Phases 1–5 are complete.**
### Phase 6 — vocabulary rename + annotation DSL ✅ DONE (follow-on request)
Renamed the core nouns: `game`/`module` → `program`, `main` → `entry`,
`component` → `property`, `archetype` → `model`, `system` → `handler`. Done via a
transitional self-hosting bootstrap (accept both → reseed → move the compiler's
own source to new keywords + tighten → reseed); old keywords now rejected.
Token-safe corpus migration ([rename_kw.c](tools/ludic-tools/rename_kw.c)), goldens
byte-identical. Reconciled the LSP indexer, editor vocab, check-docs wrapper, and
docs; fixed two pre-existing toolchain bugs (a `set -e` bug in build-tools.sh that
blocked all editor-binary rebuilds, and a stale LSP test offset).
Added an **annotation DSL**: `@Queries(these: [Prop{constraint}, …], on: Model)` on
a handler desugars to the existing `S_QUERY` loop (each property binds by its own
name; a `Prop{…}` constraint qualifies its bare fields; `on:` adds a `{Model}`
tag), and `@Handles(…)` on a program parses as documentation. See
[examples/lang/annotations.ludic](examples/lang/annotations.ludic); bin/x test 15/15. All thirteen findings are resolved or resolved by an
explicit, documented decision.
---
## Decision log
- **Scope:** full redesign, Phases 0–5. *(chosen)*
- **Named fields:** colon everywhere; `=` is binding-only. *(chosen)*
- **Open — Phase 4 detail:** typed `enum` state vs. keep integer registers.
Recommended: typed enums (fixes #13), but it's the deepest change; can be
deferred without blocking Phases 1–3.
- **Open — ui props:** paren named-args (`panel(id: Root)`) vs. colonized
whitespace (`panel id: Root`). Recommended: paren form for full cohesion.
- **Open — bitwise ops:** functions (status quo, documented) vs. operators.

View file

@ -55,7 +55,7 @@ bin/ludic examples/lang/offline_rewards.ludic # compile + run a plain progra
## `networking/` — deterministic multiplayer (N0–N6)
Each maps to a stage of [`NETWORKING-DESIGN.md`](../NETWORKING-DESIGN.md); all run
Each maps to a stage of [the Networking design](https://git.workshopsoft.io/workshopsoft/ludic/wiki/Design/Networking); all run
over the compiler's built-in loopback transport with zero foreign code.
| Example | What it shows |

View file

@ -1,4 +1,4 @@
# emit_net.ludic — NETWORKING N2–N6 codegen (NETWORKING-DESIGN.md).
# emit_net.ludic — NETWORKING N2–N6 codegen (see the Networking design on the wiki: Design/Networking).
#
# A program that calls net_send/net_poll with no `extern fn` override triggers the
# built-in loopback transport; this flag defers its emission to end-of-module.