feat(stdlib): namespaced standard library (issue #2)

Implement the bulk of the namespaced-stdlib proposal (workshopsoft/ludic#2):
156 namespace methods across Math, Text, List, Ease, Collide, World, Net,
Sys, Save, Mem, extended Screen, Color functions, extended Random, and Time.
All deterministic fixed-point; self-hosting (C-free bootstrap fixpoint holds).

Compiler (selfhost/):
- Math.*: sqrt/sin/cos/tan/atan2/asin/acos (fixed-point runtime prelude —
  bit-by-bit isqrt, 256-entry interpolated sine table, Ross atan2), plus
  hypot/dist/dist2/deg_to_rad/rad_to_deg/posmod/wrap/ping_pong/snapped/
  move_toward/smoothstep/lerp/remap/sign/floor/ceil/round.
- Text.* (complete): upper/lower/trim/repeat/pad, split/join/replace,
  and the libc-backed queries.
- List.* (complete): insert/remove_at/remove/sort plus the earlier ops.
- Ease.* (in/out/in_out/back/bounce) and Collide.* (rects/point_rect/
  circles/rect_circle).
- Phase 3: World/Net/Sys/Save namespaced over the bare builtins (byte-
  identical IR) and Mem.* (bytes/words/copy/fill/peek/poke).
- Screen.* extended (line/circle/fill_circle/triangle/fill_triangle via new
  runtime primitives; sprite/sprite_scaled aliases), Color.* functions,
  Random.* (value/int/sign), Time.* (frame/delta/elapsed/now — new
  game-loop frame counter).
- Fix a lexer bug: fixed-point literals with >4 fractional digits overflowed.

Docs & tooling:
- 129 new per-symbol doc pages; gen.py made data-driven (namespaces
  discovered from the docs, no hardcoded list); new check-impl.py enforces
  that every implemented namespace method / keyword / type / phase has a
  doc page, wired into `x test-tools`. Document the previously-undocumented
  keywords (break/continue/where/entry/new/public + and/or/not tokens).
- LSP: namespaced signature help (ns_method_sig) covering every namespace.

Tests: 12 new self-host/regression tests + a golden render for the drawing
primitives. All suites green (selfhost 21, regression 45, tools 29).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 00:26:19 +03:00
parent ff15c4e01d
commit a38195128f
235 changed files with 24676 additions and 7762 deletions

6
.gitignore vendored
View file

@ -2,9 +2,9 @@ build
**.zip
out.ppm
# self-hosted front-end binaries (built by ./build-cli.sh) and their output
/ludic
/ludicc
# the toolchain binaries (ludicc, ludic, x, ludic-fmt, ludic-lsp) — all built
# into bin/ by the one-line bootstrap + `bin/x build`; never checked in. The
# only thing published is the source and the LLVM-IR seed (selfhost/ludicc.seed.ll).
/bin/
# editor toolchain build artifacts

View file

@ -385,7 +385,7 @@ are in the appendix under "Syntax audit".
ordering constraint, not a preference.
Today, changing the grammar costs: edit `ludicc.c`, `sed` three examples and
five runtime files, run `./test.sh`. An afternoon.
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
@ -485,7 +485,7 @@ 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 `test.sh` (64 → 70), including one asserting no `.ludic`
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,
@ -531,7 +531,7 @@ 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.** `test.sh` already understands
**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.
@ -572,14 +572,24 @@ now `and`, `or`, `not`, all reserved words, with no symbol spellings at all.
## 5.7 Status — self-hosting achieved
Updated 2026-08-27. `./test.sh` = 93/93, `./tools/test-tools.sh` = 28/28,
`./selfhost/test.sh` = 5/5 including the bootstrap fixpoint.
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. Run
`./selfhost/bootstrap-cfree.sh`.
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 |
|---|---|---|
@ -589,7 +599,7 @@ with **no C compiler** — the former C compiler has been deleted. Run
| **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 — `selfhost/bootstrap-cfree.sh` |
| **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
@ -603,7 +613,7 @@ 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 (`selfhost/bootstrap.sh`):
three-generation proof (`bin/x bootstrap`):
```
stage0 build/ludicc (C) compiles selfhost.ludic -> gen1 (a Ludic-written compiler)
@ -622,11 +632,11 @@ binaries that produce the expected output.
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
`selfhost/bootstrap-cfree.sh` assembles that with clang (an IR assembler, the
`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: `selfhost/reseed.sh`
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
@ -644,14 +654,14 @@ 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**.
`build.sh` builds `build/ludicc` from the IR seed with clang and drives the
native link (headless, or windowed via `cocoa.ll`).
`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
`build.sh`) is the remaining follow-up.
the `bin/x app` build path) is the remaining follow-up.
---
@ -668,7 +678,7 @@ this order — cheapest-and-unblocking first:
4. **A6** `file_stderr` (~40).
5. **A2 + A3** `struct` + arrays (~400, landed together).
Each gets a test in `test.sh` as it lands. The suite is at 64/64; Stage 0 should
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
@ -711,8 +721,8 @@ 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 `test.sh` 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. `test.sh` already checks diagnostics — extend that corpus. |
| 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. |
@ -823,7 +833,7 @@ existing framing ("the same floor Rust and Swift stand on") already covers it.
| 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 (`selfhost/bootstrap.sh`) |
| 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.
@ -847,8 +857,8 @@ exist.
## Appendix — probe programs
Each was compiled with `./build.sh probe.ludic --headless` and run against the
current tree (`./test.sh` = 64/64).
Each was compiled with `bin/x app probe.ludic --headless` and run against the
current tree (`bin/x test` = 64/64).
**Recursion** ✅ → `55`
```ludic

View file

@ -6,24 +6,31 @@
> unchanged. `ludicc` now drives clang itself (via an `os_system` intrinsic), so
> `ludicc app.ludic -o bin/app` and `--emit-llvm` work directly, and a sibling
> command `ludic app.ludic` compiles to a temporary binary and runs it in one
> step. Build both with `./build-cli.sh`. `build.sh` remains as a convenience
> wrapper. `--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.
> step. The whole toolchain is built by `bin/x build`; `bin/x app` remains as a
> 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.
>
> 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):
>
> ```bash
> ./build-cli.sh # build ./ludicc and ./ludic (from the seed)
> ./ludicc examples/snake.ludic -o bin/snake # compile
> ./ludic examples/snake.ludic # compile + run
> # one-time bootstrap: clang assembles the seed, then ludicc compiles bin/x
> clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
> bin/x build # rebuild the whole toolchain into bin/
> # (ludicc, ludic, x, ludic-fmt, ludic-lsp)
> bin/ludicc examples/snake.ludic -o bin/snake # compile
> bin/ludic examples/snake.ludic # compile + run
> bin/x help # list every command
> ```
>
> The binaries are multi-call (one native binary under two names): invoked as
> `ludicc` it compiles, as `ludic` it compiles-and-runs. A `.ludic` file with
> systems is a game and links windowed by default; `--headless` and `--windowed`
> force the mode. The runtime (`runtime/native/cocoa.ll`) is found via
> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them at the
> repo root, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the
> `$LUDIC_HOME`, defaulting to the directory the binary sits in — keep them in
> `bin/`, or set `LUDIC_HOME` and put them on `PATH`. `$LUDIC_CC` overrides the
> assembler/linker (default `clang`).
@ -66,15 +73,19 @@ the old C driver and are **not yet re-implemented** on the self-hosted toolchain
(see the note at the top). The rows above the line work today via the
self-hosted `ludicc`.
`build.sh` wraps the common cases:
`bin/x app` wraps the common cases:
```bash
./build.sh examples/snake.ludic # -> build/snake (native)
./build.sh examples/lib/combat.ludic --lib # -> build/libcombat.* (library)
./build.sh examples/snake.ludic --headless # -> build/snake_headless (out.ppm)
./build.sh examples/snake.ludic --web # -> build/web/ (browser)
bin/x app examples/snake.ludic # -> build/snake (native)
bin/x app examples/lib/combat.ludic --lib # -> build/libcombat.* (library)
bin/x app examples/snake.ludic --headless # -> build/snake_headless (out.ppm)
bin/x app examples/snake.ludic --web # -> build/web/ (browser)
```
The `--lib` and `--web` targets were part of the old C driver and are **not yet
re-implemented** on the self-hosted toolchain — `bin/x app` supports the native
windowed and `--headless` builds today.
## Programs and libraries
> **Not yet on the self-hosted toolchain.** `--shared` and the `nm`/library
@ -208,7 +219,7 @@ only the triple changes.
```
```bash
./build.sh examples/chronorift.ludic --web
bin/x app examples/chronorift.ludic --web
python3 -m http.server -d build/web 8000 # then open http://localhost:8000/
```
@ -296,7 +307,7 @@ node tools/ludic-web/run.mjs build/web/snake_headless.wasm --stdin=ddss
```
Because Ludic is fixed-point and its RNG is seeded, the native headless binary
and the wasm one must render byte-identical frames from the same input. `test.sh`
and the wasm one must render byte-identical frames from the same input. `bin/x test`
asserts exactly that, which is a much stronger check on the backend than
"it started".
@ -310,13 +321,13 @@ entity allocator, save/load snapshots, the frame loop, the window, and the whole
graphics stack — framebuffer, PNG decoding, sprites, 9-slice, TrueType text and
the retained UI.
None of it goes through C. `./test.sh` asserts that directly: no C source
None of it goes through C. `bin/x test` asserts that directly: no C source
survives in `runtime/`, no C emitter survives in `ludicc`, and the examples all
build, run and render from IR alone.
## Every flag
The self-hosted `ludicc`/`ludic` (built with `./build-cli.sh`) accept:
The self-hosted `ludicc`/`ludic` (built with `bin/x build-cli`) accept:
```
<file.ludic> the program to compile (first non-flag argument)

View file

@ -2,7 +2,7 @@
> **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 `test.sh` check:
> 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
@ -566,7 +566,7 @@ work (and how LIFECYCLE/SCENES sequence).
`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.ludic`](examples/events.ludic) is a `test.sh` check.
(emit_stmt). [`examples/events.ludic`](examples/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

View file

@ -752,16 +752,16 @@ ludic app.ludic # compile AND run (forwards the exit code
```
`ludicc` (compile) and `ludic` (compile-and-run) are one multi-call binary built
by `./build-cli.sh`. **[COMPILING.md](COMPILING.md) is the authoritative CLI
by `bin/x build-cli`. **[COMPILING.md](COMPILING.md) is the authoritative CLI
reference** — the full flag set (`-o`, `--windowed`, `--headless`, `--emit-llvm`,
`--save-temps`, `--run`), the `LUDIC_HOME` / `LUDIC_CC` environment variables,
and the IR-to-stdout bootstrap contract (no `-o`, invoked as `ludicc`) that
`build.sh` / `reseed.sh` rely on. The default mode is auto: a file with `handler`s
`bin/x app` / `bin/x reseed` rely on. The default mode is auto: a file with `handler`s
links windowed, otherwise headless; an explicit flag always wins.
The retired C driver's `--shared`, `--fmt`, `-c`, cross-compile (`--target`) and
wasm modes are **not** on the self-hosted toolchain (see "Not yet implemented").
Source formatting now lives in the standalone `build/ludic-fmt` (below), not a
Source formatting now lives in the standalone `bin/ludic-fmt` (below), not a
compiler flag.
The self-hosted compiler is intentionally permissive: it has no separate
@ -773,10 +773,10 @@ duplicate types, unknown fields, arity) are future work.
### Editors
```bash
./tools/build-tools.sh # -> build/ludic-fmt, build/ludic-lsp
build/ludic-fmt -w src/ # format in place (keeps comments)
build/ludic-fmt --check . # CI: exit 1 if anything is unformatted
build/ludic-lsp --stdio # the language server, for any editor
bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp
bin/ludic-fmt -w src/ # format in place (keeps comments)
bin/ludic-fmt --check . # CI: exit 1 if anything is unformatted
bin/ludic-lsp --stdio # the language server, for any editor
```
`ludic-fmt` is the source formatter: it works on tokens, so comments and blank
@ -800,7 +800,7 @@ Emacs, Sublime and Zed, are in `tools/editors/` — see
- `examples/snake.ludic` — Snake, no assets — same compiler, proving generality.
```bash
./build.sh examples/snake.ludic && ./build/snake
bin/x app examples/snake.ludic && ./build/snake
```
## Not yet implemented
@ -815,7 +815,7 @@ are future work.
slices; fixed inline arrays are not accepted yet. Use `[]T` slices.
- **CLI: `--shared`, `--fmt`, and the wasm/cross target** — these were features of
the retired C driver; the self-hosted `ludicc` does not carry them (source
formatting lives in `build/ludic-fmt` instead). Output-path and IR flags are in
formatting lives in `bin/ludic-fmt` instead). Output-path and IR flags are in
flux as the CLI front-end is rebuilt — check `ludicc` usage for the current set.
Records (`property` used with `new`) and array types, `break`/`continue`, and
@ -827,7 +827,7 @@ self-hosting; their lowerings are in
> **Implemented (S0).** `scene`, `layer`, and the `on enter` / `on exit` hooks
> compile; [`examples/scenes.ludic`](examples/scenes.ludic) runs and is checked
> by `test.sh`. A scene lowers to a `machine` the compiler writes for you: one
> by `bin/x test`. A scene lowers to a `machine` the compiler writes for you: one
> 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

View file

@ -3,7 +3,7 @@
> **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/detach.ludic`](examples/detach.ludic),
> [`examples/reason.ludic`](examples/reason.ludic), `test.sh` checks). The
> [`examples/reason.ludic`](examples/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.

View file

@ -11,7 +11,7 @@ threads + sockets + a GPU) are load-bearing for everything above them.
This document is written against evidence, not memory: `luanti-org/luanti` at
`main` was cloned and read, and every claim about Ludic below was verified
against `compiler/ludicc.c` / `compiler/native.c` or by compiling a probe
program with `build/ludicc`.
program with `bin/ludicc`.
---
@ -1246,7 +1246,7 @@ process that reads it back — proving the thread pool, the compressor and the
round-trip.
**Note on the new WASM target.** A peer session just landed WebAssembly support
(`runtime/web/`, `test.sh` now 64/64), and `@main` is now split into
(`runtime/web/`, `bin/x test` now 64/64), and `@main` is now split into
`ludic_boot/frame/alive/teardown` so a browser can drive the loop from
`requestAnimationFrame`. Two consequences for this roadmap: (a) threads on
wasm32 mean Web Workers + SharedArrayBuffer, not pthreads, so **G-17 needs a

View file

@ -289,7 +289,7 @@ 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`/`test.sh` before any
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.

View file

@ -4,12 +4,12 @@
> 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 `test.sh` (checks `net_echo` … `net_demo`)
> asserts itself from its own `entry`. See `bin/x test` (checks `net_echo` … `net_demo`)
> and `examples/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 `test.sh`
> _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

View file

@ -22,7 +22,7 @@ compiler intrinsics, the same floor Rust and Swift stand on.
lexer, parser and LLVM-IR backend — that compiles every example (including the
6-file JRPG) to the byte-exact same binary the original C compiler produced, and
compiles **its own source** to a fixpoint. It is built from a checked-in IR seed
(`selfhost/ludicc.seed.ll`) with clang alone; run `./selfhost/bootstrap-cfree.sh`
(`selfhost/ludicc.seed.ll`) with clang alone; run `bin/x bootstrap-cfree`
to rebuild it with no C compiler in the loop. The former C compiler is gone.
Neither the compiler nor the games are C. Beneath both sits only the platform's
@ -35,8 +35,8 @@ self-hosted native toolchain.)
| Path | What it is |
|------|-----------|
| `selfhost/*.ludic` | **the compiler, written in Ludic** — lexer, parser, and the LLVM-IR backend (ECS storage, queries, spawn, `match`/`machine`, UI, scenes, save/load, fixed-point). Concatenated by `selfhost/build.sh`; built from `selfhost/ludicc.seed.ll` |
| `selfhost/bootstrap-cfree.sh` | rebuild the compiler from the IR seed with **no C compiler**, and prove it reproduces its own IR |
| `selfhost/*.ludic` | **the compiler, written in Ludic** — lexer, parser, and the LLVM-IR backend (ECS storage, queries, spawn, `match`/`machine`, UI, scenes, save/load, fixed-point). Concatenated by `bin/x selfhost-build`; built from `selfhost/ludicc.seed.ll` |
| `tools/x/*.ludic` | **the task runner, written in Ludic** — one binary (`bin/x`) that builds, tests, bootstraps and reseeds the whole project, replacing every shell script. `bin/x bootstrap-cfree` rebuilds the compiler from the IR seed with **no C compiler** and proves it reproduces its own IR |
| `runtime/native/core.ludic` | the runtime written *in Ludic* for the native path (framebuffer, text, registers, RNG, input) |
| `COMPILING.md` | the native pipeline: `ludicc → LLVM IR → exe/dylib`, `module`/`export`, cross-compilation, `rt_*` intrinsics |
| `runtime/native/image.ludic` | PNG decoding, images, sprites, alpha blending and 9-slice — in Ludic |
@ -51,58 +51,50 @@ self-hosted native toolchain.)
| `examples/chronorift.ludic` | the JRPG written in Ludic (multi-file via `import`, model-based) |
| `examples/menu.ludic` | a retained-UI title screen (9-slice, TrueType, focusable buttons) |
| `examples/snake.ludic` | a second, unrelated game — proves the language is general (same toolchain, no engine hardcoding) |
| `build.sh` | `./build.sh examples/<name>.ludic` |
| `test.sh` | regression suite: builds the compiler, compiles/runs all examples, checks save/load + diagnostics (`./test.sh`) |
| `tools/ludic-tools/` | the editor toolchain, in C: `ludic-fmt` (source formatter) and `ludic-lsp` (language server) — one lexer and one vocabulary shared by both |
| `bin/x` | the task runner — `bin/x app examples/<name>.ludic` compiles a program; `bin/x help` lists every command |
| `bin/x test` | regression suite: builds the compiler, compiles/runs all examples, checks save/load + diagnostics |
| `tools/ludic-tools/` | the editor toolchain, **in Ludic**: `ludic-fmt` (source formatter) and `ludic-lsp` (language server) — one lexer and one vocabulary shared by both |
| `tools/editors/` | plugins for VS Code and JetBrains, plus configuration for Neovim, Helix, Emacs, Sublime and Zed ([README](tools/editors/README.md)) |
| `tools/test-tools.sh` | regression suite for the toolchain: proves formatting never changes a program, drives the language server over real LSP traffic |
| `bin/x test-tools` | regression suite for the toolchain: proves formatting never changes a program, drives the language server over real LSP traffic |
## Build & run
`ludicc` compiles Ludic straight to machine code via LLVM IR. See
**[COMPILING.md](COMPILING.md)** for the pipeline, shared libraries
(`--shared`), cross-compilation and the runtime protocol.
Everything is driven by `bin/x`, the project's task runner — one native binary,
written in Ludic and compiled by Ludic, that replaces every build/test/bootstrap
shell script. Bootstrap it once from a clean checkout (the only step Ludic can't
do for itself, since compiling Ludic needs a compiler):
```bash
./build.sh examples/chronorift.ludic
clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc tools/x/main.ludic -o bin/x
```
Then `bin/x build` builds the whole toolchain into `bin/` — including `bin/x`
itself — and `bin/x help` lists every command. `ludicc` compiles Ludic straight
to machine code via LLVM IR; see **[COMPILING.md](COMPILING.md)** for the
pipeline and the runtime protocol.
```bash
bin/x app examples/chronorift.ludic
./build/chronorift # opens a native window
```
Headless render (for testing / CI):
```bash
./build.sh examples/chronorift.ludic --headless
bin/x app examples/chronorift.ludic --headless
printf 'ddddwww' | ./build/chronorift_headless # writes out.ppm
sips -s format png out.ppm --out frame.png
```
A `module` compiles to a shared library instead of a program:
```bash
./build.sh examples/lib/combat.ludic --lib # -> build/libcombat.dylib
```
In a browser:
```bash
./build.sh examples/chronorift.ludic --web # -> build/web/
python3 -m http.server -d build/web 8000 # open http://localhost:8000/
```
`build/web/` is a self-contained 116 KB directory — the 42 KB module, the loader,
a page, and the sprites the compiler saw the game name. Copy it to any static
host and it runs; it needs no server-side anything and no special headers. Saves
go to `localStorage`, so `save()`/`load()` survive a reload.
A wasm build needs an LLVM with the WebAssembly backend and `wasm-ld` — Linux
`clang`/`lld` have both, Apple's clang has neither (`brew install llvm lld`).
See **[COMPILING.md](COMPILING.md#the-web)** for the pipeline, who owns the frame
loop, and how assets are bundled.
(The shared-library and wasm/web build paths lived in the old C compiler and are
not yet re-implemented on the self-hosted native toolchain, so `bin/x app` builds
native windowed and `--headless` targets today. See
**[COMPILING.md](COMPILING.md)** for the pipeline and the runtime protocol.)
## Editor support
```bash
./tools/build-tools.sh # -> build/ludic-fmt, build/ludic-lsp
bin/x tools # -> bin/ludic-fmt, bin/ludic-lsp
```
`ludic-lsp` speaks LSP 3.17 over stdio, so one binary serves every editor:
@ -172,7 +164,7 @@ Controls:
- [x] Random encounters + turn-based battle (HP/MP, seeded-RNG damage)
- [x] Party + **local co-op** (P1 Knight, P2 Mage, per-player turns)
- [x] Leveling (XP → stat growth) and game-over / respawn
- [x] **Self-hosting**: a Ludic-written compiler compiles its own source to a byte-exact fixpoint, and rebuilds itself from a checked-in IR seed with no C compiler in the loop (`selfhost/`, `./selfhost/bootstrap-cfree.sh`)
- [x] **Self-hosting**: a Ludic-written compiler compiles its own source to a byte-exact fixpoint, and rebuilds itself from a checked-in IR seed with no C compiler in the loop (`selfhost/`, `bin/x bootstrap-cfree`)
- [x] Snapshot save / load (full ECS World)
- [x] Two maps (overworld + dungeon) with map switching via the arch
- [x] Boss encounter (Rift Warden) + victory condition

View file

@ -3,7 +3,7 @@
> **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/scenes.ludic`](examples/scenes.ludic),
> a `test.sh` check). The extensions in §4–§8 (scene-owned entities, richer
> 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.
@ -310,7 +310,7 @@ Each is independently shippable and testable, matching how the repo phases work.
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/scenes.ludic`](examples/scenes.ludic) compiles, runs, and is checked
by `test.sh`. This is the floor everything else builds on.
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

View file

@ -5,7 +5,7 @@ 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 (`./test.sh` 14/14), and each syntax migration was proven
> 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`.
@ -173,8 +173,8 @@ machine R_PHASE { var phase: Phase = Phase.KnightMenu
## Phase sequence
Each phase is independently shippable and ends green on `./test.sh` +
`./selfhost/test.sh` (fixpoint).
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
@ -198,14 +198,14 @@ Made spec ⇄ compiler agree **before** any grammar change. What landed:
- ✅ **`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.
- ✅ **`test.sh` guards drift** — added a `qsmoke qdecl` compile check. Suite
- ✅ **`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 `test.sh`); COMPILING.md updated by that 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 `test.sh` (it can't
- **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).
@ -226,7 +226,7 @@ Landed on branch `syntax-redesign-phase2` (baseline committed on `main` first).
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; `./test.sh` 14/14; all goldens byte-identical; qdecl runs correctly.
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.
@ -248,7 +248,7 @@ agree). Flagged to the toolchain owners.
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, `test.sh` 14/14. Doc examples
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
@ -257,7 +257,7 @@ 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, `test.sh` 14/14.
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.)
@ -277,7 +277,7 @@ 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, `test.sh` 14/14, goldens byte-identical.
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.
@ -359,7 +359,7 @@ Added an **annotation DSL**: `@Queries(these: [Prop{constraint}, …], on: Model
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/annotations.ludic](examples/annotations.ludic); test.sh 15/15. All thirteen findings are resolved or resolved by an
[examples/annotations.ludic](examples/annotations.ludic); bin/x test 15/15. All thirteen findings are resolved or resolved by an
explicit, documented decision.
---

View file

@ -1,26 +0,0 @@
#!/bin/bash
# build-cli.sh — build the self-hosted Ludic front-end binaries.
#
# Both `ludicc` and `ludic` are the SAME native binary, compiled from the Ludic
# compiler's own IR seed (selfhost/ludicc.seed.ll) with clang alone — no C
# compiler. The binary is multi-call: it looks at argv[0], so invoked as
# `ludicc` it compiles, and invoked as `ludic` it compiles and runs.
#
# ./build-cli.sh
# ./ludicc examples/snake.ludic -o build/snake # compile
# ./ludic examples/snake.ludic # compile + play
#
# The binaries locate the runtime (runtime/native/cocoa.ll) via $LUDIC_HOME,
# defaulting to the directory they sit in — so keep them at the repo root, or
# set LUDIC_HOME and put them on your PATH.
set -eu
cd "$(dirname "$0")"
CC="${LUDIC_CC:-clang}"
mkdir -p build
echo "cc: selfhost/ludicc.seed.ll -> ludicc/ludic (from the IR seed, no C compiler)"
$CC selfhost/ludicc.seed.ll -o build/ludicc
cp build/ludicc ludicc
cp build/ludicc ludic
chmod +x ludicc ludic
echo "done. built ./ludicc and ./ludic"

View file

@ -1,52 +0,0 @@
#!/bin/bash
# build.sh — compile a Ludic program with the self-hosted Ludic compiler.
#
# The compiler is written in Ludic (selfhost/*.ludic). It is built from the
# checked-in LLVM-IR seed (selfhost/ludicc.seed.ll) with clang alone — no C
# compiler is involved anywhere. clang then assembles the emitted IR and links
# it; a windowed build also links the hand-written platform layer (cocoa.ll).
#
# ./build.sh examples/snake.ludic # windowed native app
# ./build.sh examples/snake.ludic --headless # headless (renders out.ppm)
set -u
cd "$(dirname "$0")"
CC="${LUDIC_CC:-clang}"
mkdir -p build
# the compiler itself: assembled from the IR seed (C-free)
if [ ! -x build/ludicc ] || [ selfhost/ludicc.seed.ll -nt build/ludicc ]; then
echo "cc: selfhost/ludicc.seed.ll -> build/ludicc (from the IR seed, no C compiler)"
$CC selfhost/ludicc.seed.ll -o build/ludicc || exit 1
fi
SRC=""; MODE=windowed; SAVE=0
for a in "$@"; do
case "$a" in
--headless) MODE=headless ;;
--windowed) MODE=windowed ;;
--save-temps) SAVE=1 ;;
-*) ;;
*) SRC="$a" ;;
esac
done
[ -n "$SRC" ] || { echo "usage: ./build.sh <file.ludic> [--headless]"; exit 1; }
NAME="$(basename "${SRC%.ludic}")"
case "$MODE" in
windowed)
OUT="build/$NAME"
echo "ludicc: $SRC -> $OUT (Ludic-written compiler -> LLVM IR -> windowed binary)"
build/ludicc --windowed "$SRC" > "$OUT.ll" || exit 1
$CC -O2 "$OUT.ll" runtime/native/cocoa.ll -framework Cocoa -Wl,-rpath,@loader_path -o "$OUT" || exit 1
[ "$SAVE" = 1 ] || rm -f "$OUT.ll"
echo "done. run: ./$OUT (from the repo root, so assets/ resolves)"
;;
headless)
OUT="build/${NAME}_headless"
echo "ludicc: $SRC -> $OUT (renders the last frame to out.ppm)"
build/ludicc --headless "$SRC" > "$OUT.ll" || exit 1
$CC -O2 "$OUT.ll" -o "$OUT" || exit 1
[ "$SAVE" = 1 ] || rm -f "$OUT.ll"
echo "done. run: printf 'ddss' | ./$OUT && open out.ppm"
;;
esac

View file

@ -0,0 +1,7 @@
---
id: collide
title: Collide
order: 6
---
2D overlap tests on integer coordinates (pixels or tiles). Rectangles are <code>(x, y, w, h)</code> from the top-left; circles are <code>(x, y, r)</code>. Each returns a bool. Squared distances are computed in 64-bit so large coordinates never overflow.

View file

@ -0,0 +1,22 @@
---
id: collide-circles
name: Collide.circles
category: collide
kind: namespace-method
tokens: Collide.circles
sig: Collide.circles(ax, ay, ar, bx, by, br) -> bool
tip: Do two circles overlap?
order: 2
ns: Collide
member: circles
---
Returns <code>true</code> when circles centred at <code>(ax, ay)</code> and <code>(bx, by)</code> with radii <code>ar</code> and <code>br</code> overlap — that is, when the distance between centres is at most <code>ar + br</code>. It compares squared distances internally, so there is no square root and no precision loss.
```ludic
program Demo {
handler Step phase Update {
if Collide.circles(px, py, 8, ex, ey, 8) { collide() }
}
}
```

View file

@ -0,0 +1,22 @@
---
id: collide-point_rect
name: Collide.point_rect
category: collide
kind: namespace-method
tokens: Collide.point_rect
sig: Collide.point_rect(px, py, rx, ry, rw, rh) -> bool
tip: Is a point inside a rectangle?
order: 1
ns: Collide
member: point_rect
---
Returns <code>true</code> when the point <code>(px, py)</code> lies within the rectangle <code>(rx, ry, rw, rh)</code> — inclusive on the top-left edge, exclusive on the bottom-right. Use it for mouse or touch hit-testing against a button or a world region.
```ludic
program Demo {
handler Step phase Update {
if Collide.point_rect(mx, my, bx, by, bw, bh) { press() }
}
}
```

View file

@ -0,0 +1,22 @@
---
id: collide-rect_circle
name: Collide.rect_circle
category: collide
kind: namespace-method
tokens: Collide.rect_circle
sig: Collide.rect_circle(rx, ry, rw, rh, cx, cy, cr) -> bool
tip: Does a rectangle overlap a circle?
order: 3
ns: Collide
member: rect_circle
---
Returns <code>true</code> when the rectangle <code>(rx, ry, rw, rh)</code> overlaps the circle centred at <code>(cx, cy)</code> with radius <code>cr</code>. It finds the point on the rectangle nearest the circle's centre and checks whether it falls within the radius — the correct test for a round actor against a blocky tile.
```ludic
program Demo {
handler Step phase Update {
if Collide.rect_circle(tx, ty, 16, 16, bx, by, 6) { block() }
}
}
```

View file

@ -0,0 +1,22 @@
---
id: collide-rects
name: Collide.rects
category: collide
kind: namespace-method
tokens: Collide.rects
sig: Collide.rects(ax, ay, aw, ah, bx, by, bw, bh) -> bool
tip: Do two rectangles overlap?
order: 0
ns: Collide
member: rects
---
Returns <code>true</code> when the axis-aligned rectangles <code>(ax, ay, aw, ah)</code> and <code>(bx, by, bw, bh)</code> overlap. Edges that merely touch do not count as overlapping. This is the standard broad-phase test for two hitboxes, tiles, or UI regions.
```ludic
program Demo {
handler Step phase Update {
if Collide.rects(px, py, 16, 16, ex, ey, 16, 16) { hit() }
}
}
```

View file

@ -0,0 +1,7 @@
---
id: color
title: Color functions
order: 6
---
Building and blending colors at runtime. Colors are <code>0x00RRGGBB</code> ints; these pack channels and transform an existing color. The named palette constants (<code>Color.Charcoal</code>, …) are a separate compile-time set.

View file

@ -0,0 +1,24 @@
---
id: color-darken
name: Color.darken
category: color
kind: namespace-method
tokens: Color.darken
sig: Color.darken(c, amount) -> int
tip: Scale a color toward black.
order: 3
ns: Color
member: darken
---
Multiplies each channel of <code>c</code> by <code>1 - amount</code> (amount a fixed 0..1), returning a darker color. <code>Color.darken(c, 0.0)</code> is unchanged and <code>1.0</code> is black. Handy for shadows and pressed-button states.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.darken(Color.Red, 0.3)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: color-lerp
name: Color.lerp
category: color
kind: namespace-method
tokens: Color.lerp
sig: Color.lerp(c0, c1, t) -> int
tip: Blend between two colors.
order: 2
ns: Color
member: lerp
---
Interpolates each channel of <code>c0</code> and <code>c1</code> by the fixed amount <code>t</code> in <code>0.0</code>..<code>1.0</code>, returning the blended color. Use it for gradients, fades between two tints, or a flash that returns to base.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.lerp(Color.Black, Color.White, 0.5)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: color-lighten
name: Color.lighten
category: color
kind: namespace-method
tokens: Color.lighten
sig: Color.lighten(c, amount) -> int
tip: Scale a color toward white.
order: 4
ns: Color
member: lighten
---
Moves each channel of <code>c</code> a fraction <code>amount</code> of the way to 255, returning a lighter color — the counterpart to <code>Color.darken</code>. Good for highlights and hover states.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.lighten(Color.Blue, 0.3)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: color-rgb
name: Color.rgb
category: color
kind: namespace-method
tokens: Color.rgb
sig: Color.rgb(r, g, b) -> int
tip: Build a color from red, green, blue.
order: 0
ns: Color
member: rgb
---
Packs three 0..255 channel values into a single <code>0xRRGGBB</code> color int. Use it to build a color from computed channels rather than a fixed literal or a palette name.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.rgb(255, 128, 64)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: color-rgba
name: Color.rgba
category: color
kind: namespace-method
tokens: Color.rgba
sig: Color.rgba(r, g, b, a) -> int
tip: Build a color with an alpha byte.
order: 1
ns: Color
member: rgba
---
Packs <code>r</code>, <code>g</code>, <code>b</code>, and an alpha <code>a</code> (0..255) into a <code>0xAARRGGBB</code> int. The software renderer draws opaque, but the alpha byte is preserved for your own blending or storage.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.rgba(255, 128, 64, 200)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,24 @@
---
id: color-with_alpha
name: Color.with_alpha
category: color
kind: namespace-method
tokens: Color.with_alpha
sig: Color.with_alpha(c, a) -> int
tip: Replace a color's alpha byte.
order: 5
ns: Color
member: with_alpha
---
Returns <code>c</code> with its alpha byte set to <code>a</code> (0..255), leaving the RGB channels untouched.
```ludic
program Demo {
handler DrawWorld phase Render {
let c = Color.with_alpha(Color.Green, 128)
Screen.clear(c)
Screen.show()
}
}
```

View file

@ -0,0 +1,27 @@
---
id: kw-break
name: break
category: control
kind: keyword
tokens: break
sig: break
tip: Leave the enclosing loop immediately.
order: 9
---
<code>break</code> exits the innermost <code>while</code> or <code>for</code> loop at once, skipping the rest of the body and any remaining iterations, and continues with the code after the loop. Use it to stop early the moment you have what you need — the first match in a scan, a collision that ends the sweep, or a guard inside <code>while true</code> that decides when to leave. It affects only the loop that contains it; an outer loop keeps running.
```ludic
program FindFirst {
var found: int = 0 - 1
handler Scan phase Update {
for i in 0 .. 10 {
if i * i > 20 {
found = i
break # stop at the first i whose square exceeds 20
}
}
}
}
```

View file

@ -0,0 +1,25 @@
---
id: kw-continue
name: continue
category: control
kind: keyword
tokens: continue
sig: continue
tip: Skip to the next iteration of the loop.
order: 10
---
<code>continue</code> abandons the rest of the current loop pass and jumps straight to the next one — the next condition check in a <code>while</code>, or the next index in a <code>for</code>. Use it to skip elements that do not apply without nesting the rest of the body inside an <code>if</code>: filter out empty slots, ignore inactive entities, or step over even numbers. Like <code>break</code>, it acts on the innermost loop only.
```ludic
program SumOdds {
var total: int = 0
handler Add phase Update {
for i in 0 .. 10 {
if i % 2 == 0 { continue } # skip even numbers
total = total + i
}
}
}
```

View file

@ -0,0 +1,25 @@
---
id: kw-where
name: where
category: control
kind: keyword
tokens: where
sig: for (Binds) in query [Comps] where cond { … }
tip: Filter a query loop to entities that satisfy a condition.
order: 11
---
<code>where</code> attaches a boolean condition to a <code>query</code> loop, so the body runs only for entities whose components satisfy it — the rows that fail the test are skipped entirely. The condition is an ordinary expression over the bound components, such as <code>where Battle.hp &lt;= 0 and Battle.side == 1</code>. It is the imperative twin of a component filter written inside a <code>@Queries</code> annotation, and keeps the guard next to the loop instead of repeating an <code>if</code> at the top of the body.
```ludic
program Cleanup {
property Health { hp: int = 0 }
model Foe { Health }
handler ReapDead phase LateUpdate {
for (Health) in query [Health, {Foe}] where Health.hp <= 0 {
# only dead foes reach here
}
}
}
```

View file

@ -0,0 +1,7 @@
---
id: ease
title: Ease
order: 6
---
Tween curves — the "juice" layer. Each takes a normalized amount <code>t</code> in <code>0.0</code>..<code>1.0</code> and returns an eased <code>fixed</code>, ready to feed to <code>Math.lerp</code>. All deterministic fixed-point.

View file

@ -0,0 +1,22 @@
---
id: ease-back
name: Ease.back
category: ease
kind: namespace-method
tokens: Ease.back
sig: Ease.back(t) -> fixed
tip: Ease in with a small backward anticipation.
order: 3
ns: Ease
member: back
---
Returns an ease-in that first dips slightly below <code>0</code> before shooting forward — the anticipation windup that gives a motion snap. Because it undershoots, the eased value can go negative near the start, so use it where a little overshoot reads as lively rather than wrong.
```ludic
program Demo {
handler Step phase Update {
let s = Math.lerp(rest, poke, Ease.back(progress))
}
}
```

View file

@ -0,0 +1,22 @@
---
id: ease-bounce
name: Ease.bounce
category: ease
kind: namespace-method
tokens: Ease.bounce
sig: Ease.bounce(t) -> fixed
tip: Ease out with a settling bounce.
order: 4
ns: Ease
member: bounce
---
Returns an ease-out that overshoots and bounces a few times before settling at <code>1.0</code>, like a ball dropping to the floor. Great for coins landing, badges popping, or a menu that lands with character.
```ludic
program Demo {
handler Step phase Update {
let y = Math.lerp(top, floor_y, Ease.bounce(progress))
}
}
```

View file

@ -0,0 +1,22 @@
---
id: ease-in
name: Ease.in
category: ease
kind: namespace-method
tokens: Ease.in
sig: Ease.in(t) -> fixed
tip: Accelerate from rest (quadratic ease-in).
order: 0
ns: Ease
member: in
---
Returns <code>t * t</code> — motion that starts slow and speeds up. Feed the result to <code>Math.lerp</code> as the blend amount so a value accelerates into its target. The input <code>t</code> is expected in <code>0.0</code>..<code>1.0</code>.
```ludic
program Demo {
handler Step phase Update {
let x = Math.lerp(start_x, end_x, Ease.in(progress))
}
}
```

View file

@ -0,0 +1,22 @@
---
id: ease-in_out
name: Ease.in_out
category: ease
kind: namespace-method
tokens: Ease.in_out
sig: Ease.in_out(t) -> fixed
tip: Ease in and then out (smoothstep).
order: 2
ns: Ease
member: in_out
---
Returns the smooth <code>3t^2 - 2t^3</code> S-curve: slow at both ends, fastest in the middle. Use it for a polished there-and-back or point-to-point move that neither jerks off the mark nor slams into the target.
```ludic
program Demo {
handler Step phase Update {
let a = Math.lerp(0.0, 1.0, Ease.in_out(progress))
}
}
```

View file

@ -0,0 +1,22 @@
---
id: ease-out
name: Ease.out
category: ease
kind: namespace-method
tokens: Ease.out
sig: Ease.out(t) -> fixed
tip: Decelerate to rest (quadratic ease-out).
order: 1
ns: Ease
member: out
---
Returns <code>t * (2 - t)</code> — motion that starts fast and slows as it arrives. The most natural-feeling easing for things settling into place, like a panel sliding in or a camera coming to rest.
```ludic
program Demo {
handler Step phase Update {
let y = Math.lerp(top, rest_y, Ease.out(progress))
}
}
```

View file

@ -0,0 +1,7 @@
---
id: list
title: List
order: 6
---
Operations over <code>[]T</code> slices — length, ends access, push/pop, swap, search, and in-place reverse. A slice is a shared growable buffer, so these mutate it in place and every holder sees the change. Arguments are positional.

View file

@ -0,0 +1,27 @@
---
id: list-clear
name: List.clear
category: list
kind: namespace-method
tokens: List.clear
sig: List.clear(s) -> void
tip: Remove all elements, keeping capacity.
order: 2
ns: List
member: clear
---
Empties <code>s</code> by setting its length back to zero. The backing buffer is kept, so refilling the slice afterward reuses the same memory without reallocating. Use it to reset a per-frame list — collisions, visible tiles, particles to draw — at the start of each update instead of building a new slice every frame.
Parameters:
- `s` — the slice to empty
```ludic
program FrameHits {
var hits: []int = new []int
handler BeginFrame phase Update {
List.clear(hits) # start the frame with an empty list
}
}
```

View file

@ -0,0 +1,31 @@
---
id: list-contains
name: List.contains
category: list
kind: namespace-method
tokens: List.contains
sig: List.contains(s, v) -> bool
tip: Whether a value appears in a slice.
order: 7
ns: List
member: contains
---
Scans <code>s</code> and returns <code>true</code> if any element equals <code>v</code>. Comparison is by value for scalar elements (ints, entities, booleans) and by identity for reference elements. Use it for membership tests — has this tile been visited, is this entity already in the target list, is the achievement unlocked. It is a linear scan, so for very large or hot-path sets a dedicated structure will be faster.
Parameters:
- `s` — the slice to search
- `v` — the value to look for
```ludic
program Visited {
var seen: []int = new []int
handler Step phase Update {
let tile = 42
if not List.contains(seen, tile) {
List.push(seen, tile)
}
}
}
```

View file

@ -0,0 +1,29 @@
---
id: list-first
name: List.first
category: list
kind: namespace-method
tokens: List.first
sig: List.first(s) -> T
tip: The first element of a slice.
order: 3
ns: List
member: first
---
Returns element <code>0</code> of <code>s</code> — the same as <code>s[0]</code>, named for readability. The slice must hold at least one element; calling it on an empty slice reads out of bounds, so guard with <code>List.len(s) > 0</code> when that is possible. Use it to peek at the head of a queue or the front of an ordered list.
Parameters:
- `s` — a non-empty slice
```ludic
program Queue {
var waiting: []int = new []int
handler Serve phase Update {
if List.len(waiting) > 0 {
let next = List.first(waiting)
}
}
}
```

View file

@ -0,0 +1,31 @@
---
id: list-index_of
name: List.index_of
category: list
kind: namespace-method
tokens: List.index_of
sig: List.index_of(s, v) -> int
tip: The index of a value in a slice, or -1 if absent.
order: 8
ns: List
member: index_of
---
Scans <code>s</code> and returns the index of the first element equal to <code>v</code>, or <code>-1</code> if none match. It uses the same value/identity comparison as <code>List.contains</code>, but tells you where the match is so you can act on it — swap it out, read a parallel slice at the same index, or guard with <code>if idx >= 0</code>. Only the first match is reported.
Parameters:
- `s` — the slice to search
- `v` — the value to locate
```ludic
program FindSlot {
var ids: []int = new []int
handler Locate phase Update {
let slot = List.index_of(ids, 99)
if slot >= 0 {
List.swap(ids, slot, List.len(ids) - 1)
}
}
}
```

View file

@ -0,0 +1,22 @@
---
id: list-insert
name: List.insert
category: list
kind: namespace-method
tokens: List.insert
sig: List.insert(s, i, v) -> void
tip: Insert an element at an index, shifting the rest up.
order: 10
ns: List
member: insert
---
Inserts <code>v</code> at index <code>i</code>, moving the elements at and after <code>i</code> one place toward the end and growing the backing buffer if needed. Use it to keep a list ordered as you build it, or to splice an item into the middle. Appending is cheaper with <code>List.push</code>.
```ludic
program Demo {
handler Step phase Update {
List.insert(queue, 0, next_id) # push to the front
}
}
```

View file

@ -0,0 +1,29 @@
---
id: list-last
name: List.last
category: list
kind: namespace-method
tokens: List.last
sig: List.last(s) -> T
tip: The last element of a slice.
order: 4
ns: List
member: last
---
Returns the final element of <code>s</code> — the value at index <code>List.len(s) - 1</code>. Like <code>List.first</code>, it assumes the slice is non-empty, so guard with a length check when the slice might be empty. Use it to read the most recently pushed item without removing it; use <code>List.pop</code> when you want to take it off.
Parameters:
- `s` — a non-empty slice
```ludic
program Trail {
var points: []int = new []int
handler Head phase Update {
if List.len(points) > 0 {
let newest = List.last(points)
}
}
}
```

View file

@ -0,0 +1,27 @@
---
id: list-len
name: List.len
category: list
kind: namespace-method
tokens: List.len
sig: List.len(s) -> int
tip: The number of elements in a slice.
order: 0
ns: List
member: len
---
Returns how many elements <code>s</code> currently holds. It reads the slice's length, which changes as you <code>List.push</code> and <code>List.pop</code>, and is the same value as the bare <code>len(s)</code>. Use it to bound a loop, test for emptiness, or find the last index with <code>List.len(s) - 1</code>.
Parameters:
- `s` — the slice to measure
```ludic
program CountEnemies {
var enemies: []int = new []int
handler Report phase Update {
let n = List.len(enemies)
}
}
```

View file

@ -0,0 +1,29 @@
---
id: list-pop
name: List.pop
category: list
kind: namespace-method
tokens: List.pop
sig: List.pop(s) -> T
tip: Remove and return the last element.
order: 5
ns: List
member: pop
---
Removes the final element of <code>s</code> and returns it, shortening the slice by one. Together with <code>List.push</code> this makes a slice work as a stack: push to add, pop to take the most recent back off. The slice must be non-empty. Use it to unwind a history, process a work list, or undo the last action.
Parameters:
- `s` — a non-empty slice
```ludic
program UndoStack {
var history: []int = new []int
handler Undo phase Update {
if List.len(history) > 0 {
let last_action = List.pop(history)
}
}
}
```

View file

@ -0,0 +1,28 @@
---
id: list-push
name: List.push
category: list
kind: namespace-method
tokens: List.push
sig: List.push(s, v) -> void
tip: Append an element to the end of a slice.
order: 1
ns: List
member: push
---
Adds <code>v</code> to the end of <code>s</code>, growing the backing buffer if needed, so the slice's length increases by one. It is the same operation as the bare <code>push(s, v)</code>. Because a slice is shared, the new element is visible to every holder. Use it to collect spawned entities, build a list of hits, or fill an inventory.
Parameters:
- `s` — the slice to append to
- `v` — the element to add (matching the slice's element type)
```ludic
program Collect {
var picked: []int = new []int
handler Grab phase Update {
List.push(picked, 7)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: list-remove
name: List.remove
category: list
kind: namespace-method
tokens: List.remove
sig: List.remove(s, v) -> void
tip: Remove the first element equal to a value.
order: 12
ns: List
member: remove
---
Scans for the first element equal to <code>v</code> and removes it (shifting the rest down); if no element matches, the slice is unchanged. Comparison is by value for scalars and by identity for reference elements, matching <code>List.contains</code>. Only the first match is removed.
```ludic
program Demo {
handler Step phase Update {
List.remove(active, dead_entity)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: list-remove_at
name: List.remove_at
category: list
kind: namespace-method
tokens: List.remove_at
sig: List.remove_at(s, i) -> void
tip: Remove the element at an index, shifting the rest down.
order: 11
ns: List
member: remove_at
---
Removes the element at index <code>i</code>, moving everything after it one place toward the front and shortening the slice by one. Order is preserved. When order does not matter, swapping the item to the end and calling <code>List.pop</code> is cheaper.
```ludic
program Demo {
handler Step phase Update {
List.remove_at(effects, expired)
}
}
```

View file

@ -0,0 +1,28 @@
---
id: list-reverse
name: List.reverse
category: list
kind: namespace-method
tokens: List.reverse
sig: List.reverse(s) -> void
tip: Reverse the order of a slice in place.
order: 9
ns: List
member: reverse
---
Reverses <code>s</code> so its first element becomes last and vice versa, mutating the slice in place rather than returning a copy. Use it to flip an order you built back-to-front — a path traced from goal to start, an undo stack you want oldest-first, or a list you appended to and now want to read in the opposite direction.
Parameters:
- `s` — the slice to reverse
```ludic
program PathOrder {
var path: []int = new []int
handler Finalize phase Update {
# path was built from the goal back to the start
List.reverse(path) # now start -> goal
}
}
```

View file

@ -0,0 +1,22 @@
---
id: list-sort
name: List.sort
category: list
kind: namespace-method
tokens: List.sort
sig: List.sort(s) -> void
tip: Sort a slice in ascending order, in place.
order: 13
ns: List
member: sort
---
Sorts the elements of <code>s</code> in ascending order in place, using an insertion sort. It is intended for slices of scalar elements (ints, entities, fixed) where <code>&lt;</code> is meaningful; reference elements are ordered by identity, which is rarely useful. Insertion sort is simple and fast for the small, nearly-sorted lists games usually hold.
```ludic
program Demo {
handler Step phase Update {
List.sort(scores)
}
}
```

View file

@ -0,0 +1,32 @@
---
id: list-swap
name: List.swap
category: list
kind: namespace-method
tokens: List.swap
sig: List.swap(s, i, j) -> void
tip: Exchange the elements at two indices.
order: 6
ns: List
member: swap
---
Exchanges the elements at indices <code>i</code> and <code>j</code> in <code>s</code>, in place. Both indices must be within the slice. Swapping is the building block of shuffles, sorts, and moving an item to the end before <code>List.pop</code> to remove it cheaply without shifting the rest.
Parameters:
- `s` — the slice to modify
- `i` — the first index
- `j` — the second index
```ludic
program RemoveFast {
var actors: []int = new []int
handler Kill phase Update {
let dead = 2
let last = List.len(actors) - 1
List.swap(actors, dead, last) # move it to the end...
let removed = List.pop(actors) # ...then drop it
}
}
```

View file

@ -0,0 +1,7 @@
---
id: math
title: Math
order: 6
---
Deterministic fixed-point math. Every function is computed in Q16.16 with plain integer arithmetic, so results are bit-identical on every platform and every run — the same guarantee the rest of the runtime gives. Arguments are positional.

View file

@ -0,0 +1,28 @@
---
id: math-abs
name: Math.abs
category: math
kind: namespace-method
tokens: Math.abs
sig: Math.abs(x) -> int
tip: The magnitude of a value, dropping its sign.
order: 2
ns: Math
member: abs
---
Returns the absolute value of <code>x</code> — its distance from zero, always non-negative. It preserves the type of its argument, so <code>Math.abs</code> of a <code>fixed</code> stays <code>fixed</code>. Reach for it when you care about how far apart two things are regardless of direction, such as the gap between a target and current position, or the speed behind a signed velocity. The bare form <code>abs(x)</code> is kept as a convenience alias.
Parameters:
- `x` — the value whose magnitude you want
```ludic
program ChaseDistance {
var target: int = 40
var here: int = 12
handler Report phase Update {
let gap = Math.abs(target - here) # 28, regardless of which is larger
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-acos
name: Math.acos
category: math
kind: namespace-method
tokens: Math.acos
sig: Math.acos(x) -> fixed
tip: The arccosine of a value, in radians.
order: 28
ns: Math
member: acos
---
Returns the angle in radians whose cosine is <code>x</code>, in <code>0</code>..<code>pi</code>. The input should be in <code>-1.0</code>..<code>1.0</code>. Handy for recovering the angle between two normalized directions from their dot product.
```ludic
program Demo {
handler Step phase Update {
let between = Math.acos(dot)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-asin
name: Math.asin
category: math
kind: namespace-method
tokens: Math.asin
sig: Math.asin(x) -> fixed
tip: The arcsine of a value, in radians.
order: 27
ns: Math
member: asin
---
Returns the angle in radians whose sine is <code>x</code>, in <code>-pi/2</code>..<code>pi/2</code>. The input should be in <code>-1.0</code>..<code>1.0</code>. Built from <code>Math.atan2</code> and <code>Math.sqrt</code>, so it is deterministic.
```ludic
program Demo {
handler Step phase Update {
let angle = Math.asin(height / radius)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-atan2
name: Math.atan2
category: math
kind: namespace-method
tokens: Math.atan2
sig: Math.atan2(y, x) -> fixed
tip: The angle of the vector (x, y), in radians.
order: 26
ns: Math
member: atan2
---
Returns the angle in radians from the positive x-axis to the point <code>(x, y)</code>, in the full range <code>-pi</code>..<code>pi</code> — the standard way to get a heading from a direction vector. Note the argument order is <code>y</code> then <code>x</code>. Deterministic fixed-point, accurate to within about a degree.
```ludic
program Demo {
handler Step phase Update {
let heading = Math.atan2(target_y - y, target_x - x)
}
}
```

View file

@ -0,0 +1,29 @@
---
id: math-ceil
name: Math.ceil
category: math
kind: namespace-method
tokens: Math.ceil
sig: Math.ceil(x) -> int
tip: Round a fixed value up to the nearest whole int.
order: 6
ns: Math
member: ceil
---
Takes a <code>fixed</code> value and returns the smallest <code>int</code> not less than it — rounding toward positive infinity, so <code>Math.ceil(2.1)</code> is <code>3</code> and <code>Math.ceil(4.0)</code> stays <code>4</code>. Use it when you need to cover a fractional amount with whole units: how many full tiles a span touches, or how many rows a variable-height list needs.
Parameters:
- `x` — the `fixed` value to round up
```ludic
program RowsNeeded {
const ROW_HEIGHT: fixed = 12.0
var content_height: fixed = 30.0
handler Layout phase Update {
let rows = Math.ceil(content_height / ROW_HEIGHT) # 3
}
}
```

View file

@ -0,0 +1,31 @@
---
id: math-clamp
name: Math.clamp
category: math
kind: namespace-method
tokens: Math.clamp
sig: Math.clamp(v, lo, hi) -> int
tip: Constrain a value to the range [lo, hi].
order: 3
ns: Math
member: clamp
---
Returns <code>v</code> held inside the inclusive range <code>lo</code>..<code>hi</code>: below <code>lo</code> it returns <code>lo</code>, above <code>hi</code> it returns <code>hi</code>, otherwise <code>v</code> unchanged. It is the single-call form of <code>Math.max(lo, Math.min(v, hi))</code> and works for <code>int</code> and <code>fixed</code> alike. Use it to keep a camera inside the world, a slider within its track, or a stat within its legal bounds. The bare form <code>clamp(v, lo, hi)</code> is kept as a convenience alias.
Parameters:
- `v` — the value to constrain
- `lo` — the lowest allowed result (inclusive)
- `hi` — the highest allowed result (inclusive)
```ludic
program CameraBounds {
const WORLD_WIDTH: int = 320
var camera_x: int = 0
handler Follow phase Update {
camera_x = Math.clamp(camera_x, 0, WORLD_WIDTH - 1)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-cos
name: Math.cos
category: math
kind: namespace-method
tokens: Math.cos
sig: Math.cos(radians) -> fixed
tip: Cosine of an angle in radians.
order: 13
ns: Math
member: cos
---
Returns the cosine of <code>radians</code> as a <code>fixed</code> in <code>-1.0</code>..<code>1.0</code>, computed as <code>Math.sin(radians + pi/2)</code> off the same deterministic table. Use <code>Math.cos</code> and <code>Math.sin</code> together to convert an angle into an <code>(x, y)</code> direction — for orbits, aiming, or steering.
```ludic
program Demo {
handler Step phase Update {
let x = center_x + Math.cos(angle) * radius
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-deg_to_rad
name: Math.deg_to_rad
category: math
kind: namespace-method
tokens: Math.deg_to_rad
sig: Math.deg_to_rad(degrees) -> fixed
tip: Convert degrees to radians.
order: 18
ns: Math
member: deg_to_rad
---
Converts an angle from degrees to radians (multiplying by <code>pi/180</code>), since <code>Math.sin</code>/<code>Math.cos</code>/<code>Math.tan</code> all take radians. Use it when your data or design speaks in degrees — a 90-degree turn, a 45-degree cone — and you need to feed it to the trig functions.
```ludic
program Demo {
handler Step phase Update {
let r = Math.sin(Math.deg_to_rad(90.0)) # ~1.0
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-dist
name: Math.dist
category: math
kind: namespace-method
tokens: Math.dist
sig: Math.dist(x0, y0, x1, y1) -> fixed
tip: Distance between two points.
order: 16
ns: Math
member: dist
---
Returns the straight-line distance between <code>(x0, y0)</code> and <code>(x1, y1)</code> — <code>sqrt(dx*dx + dy*dy)</code> in fixed-point. Use it for proximity and range checks. When you only need to compare distances (which is nearer? within radius?), prefer <code>Math.dist2</code>, which skips the square root.
```ludic
program Demo {
handler Step phase Update {
if Math.dist(px, py, ex, ey) < attack_range { strike() }
}
}
```

View file

@ -0,0 +1,23 @@
---
id: math-dist2
name: Math.dist2
category: math
kind: namespace-method
tokens: Math.dist2
sig: Math.dist2(x0, y0, x1, y1) -> fixed
tip: Squared distance between two points.
order: 17
ns: Math
member: dist2
---
Returns the squared distance between <code>(x0, y0)</code> and <code>(x1, y1)</code> — <code>dx*dx + dy*dy</code>, with no square root. Comparing squared distances gives the same ordering as comparing real distances, so this is the cheaper choice for "nearest" and "within radius" tests: compare against <code>radius * radius</code> instead of calling <code>Math.dist</code>.
```ludic
program Demo {
handler Step phase Update {
let r2 = radius * radius
if Math.dist2(px, py, ex, ey) < r2 { in_range() }
}
}
```

View file

@ -0,0 +1,29 @@
---
id: math-floor
name: Math.floor
category: math
kind: namespace-method
tokens: Math.floor
sig: Math.floor(x) -> int
tip: Round a fixed value down to the nearest whole int.
order: 5
ns: Math
member: floor
---
Takes a <code>fixed</code> value and returns the largest <code>int</code> not greater than it — rounding toward negative infinity, so <code>Math.floor(2.7)</code> is <code>2</code> and <code>Math.floor(-0.2)</code> is <code>-1</code>. This is the fixed-to-int conversion you reach for when turning a smooth position into a whole tile or pixel index. It is the same operation as the bare <code>flr(x)</code>.
Parameters:
- `x` — the `fixed` value to round down
```ludic
program TileIndex {
const TILE_SIZE: fixed = 16.0
var world_x: fixed = 40.5
handler Locate phase Update {
let column = Math.floor(world_x / TILE_SIZE) # 2
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-hypot
name: Math.hypot
category: math
kind: namespace-method
tokens: Math.hypot
sig: Math.hypot(x, y) -> fixed
tip: Length of the vector (x, y).
order: 15
ns: Math
member: hypot
---
Returns <code>sqrt(x*x + y*y)</code> — the length of the 2D vector <code>(x, y)</code>, or equivalently the hypotenuse of a right triangle with those legs. It is the one-call form for a magnitude and reads more clearly than spelling out the squares and root. To normalize a vector, divide each component by its <code>Math.hypot</code>.
```ludic
program Demo {
handler Step phase Update {
let length = Math.hypot(velocity_x, velocity_y)
}
}
```

View file

@ -0,0 +1,31 @@
---
id: math-inverse_lerp
name: Math.inverse_lerp
category: math
kind: namespace-method
tokens: Math.inverse_lerp
sig: Math.inverse_lerp(a, b, v) -> fixed
tip: Find where a value sits between two endpoints as a 0..1 fraction.
order: 9
ns: Math
member: inverse_lerp
---
The inverse of <code>Math.lerp</code>: given endpoints <code>a</code> and <code>b</code> and a value <code>v</code>, it returns the fraction <code>(v - a) / (b - a)</code> — <code>0.0</code> when <code>v</code> equals <code>a</code>, <code>1.0</code> when it equals <code>b</code>. Use it to turn a raw quantity into a normalized amount: how full a health bar is, how far a timer has run, or the <code>t</code> to feed into another <code>Math.lerp</code>. The endpoints must differ, since equal ones would divide by zero.
Parameters:
- `a` — the endpoint that maps to `0.0`
- `b` — the endpoint that maps to `1.0`
- `v` — the value to locate between them
```ludic
program HealthFraction {
const MAX_HP: fixed = 200.0
var hp: fixed = 150.0
handler Draw phase Render {
let fraction = Math.inverse_lerp(0.0, MAX_HP, hp) # 0.75
}
}
```

View file

@ -0,0 +1,30 @@
---
id: math-lerp
name: Math.lerp
category: math
kind: namespace-method
tokens: Math.lerp
sig: Math.lerp(a, b, t) -> fixed
tip: Blend between two values by a 0..1 amount.
order: 8
ns: Math
member: lerp
---
Linear interpolation: returns <code>a</code> when <code>t</code> is <code>0.0</code>, <code>b</code> when <code>t</code> is <code>1.0</code>, and a proportional blend in between — the exact value <code>a + (b - a) * t</code>. All three arguments are <code>fixed</code> and the result is <code>fixed</code>. It is the workhorse behind smooth motion: ease a camera toward a target, fade a value over time, or sample a gradient. Values of <code>t</code> outside <code>0..1</code> extrapolate past the endpoints.
Parameters:
- `a` — the value returned at `t = 0.0`
- `b` — the value returned at `t = 1.0`
- `t` — the blend amount, normally `0.0`..`1.0`
```ludic
program EaseCamera {
var camera_x: fixed = 0.0
var target_x: fixed = 100.0
handler Follow phase Update {
camera_x = Math.lerp(camera_x, target_x, 0.1) # glides a tenth of the way each frame
}
}
```

View file

@ -0,0 +1,28 @@
---
id: math-max
name: Math.max
category: math
kind: namespace-method
tokens: Math.max
sig: Math.max(a, b) -> int
tip: The larger of two values.
order: 1
ns: Math
member: max
---
Returns whichever of <code>a</code> and <code>b</code> is larger. Like <code>Math.min</code>, it works for both <code>int</code> and <code>fixed</code> operands. Use it to hold a value at a floor — for example keeping a countdown at zero instead of going negative, or a health bar from dropping below the minimum. The bare form <code>max(a, b)</code> is kept as a convenience alias.
Parameters:
- `a` — the first value
- `b` — the second value
```ludic
program Countdown {
var timer: int = 60
handler Tick phase Update {
timer = Math.max(timer - 1, 0) # stops at 0, never negative
}
}
```

View file

@ -0,0 +1,28 @@
---
id: math-min
name: Math.min
category: math
kind: namespace-method
tokens: Math.min
sig: Math.min(a, b) -> int
tip: The smaller of two values.
order: 0
ns: Math
member: min
---
Returns whichever of <code>a</code> and <code>b</code> is smaller. It works on plain <code>int</code> values and on <code>fixed</code> values alike — both are stored as signed 32-bit words, so the comparison keeps their order either way. Use it to cap a value at a ceiling, keep a cursor inside a list, or take the nearer of two distances. The bare form <code>min(a, b)</code> is kept as a convenience alias.
Parameters:
- `a` — the first value
- `b` — the second value
```ludic
program ClampScore {
var score: int = 0
handler AddPoint phase Update {
score = Math.min(score + 1, 100) # never climbs past 100
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-move_toward
name: Math.move_toward
category: math
kind: namespace-method
tokens: Math.move_toward
sig: Math.move_toward(from, to, delta) -> fixed
tip: Step from one value toward another by at most delta.
order: 24
ns: Math
member: move_toward
---
Moves <code>from</code> toward <code>to</code> by at most <code>delta</code>, never overshooting — when the gap is smaller than <code>delta</code> it returns exactly <code>to</code>. Unlike <code>Math.lerp</code>, which eases by a fraction of the remaining distance, this moves at a constant rate, which is what you want for turrets, meters, and UI values that should approach a target steadily and then stop.
```ludic
program Demo {
handler Step phase Update {
health_shown = Math.move_toward(health_shown, health, 2.0)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-ping_pong
name: Math.ping_pong
category: math
kind: namespace-method
tokens: Math.ping_pong
sig: Math.ping_pong(t, len) -> int
tip: Bounce a counter back and forth in [0, len].
order: 22
ns: Math
member: ping_pong
---
Maps an ever-increasing counter <code>t</code> to a value that rises from <code>0</code> to <code>len</code> and back, bouncing forever — the triangle wave to <code>Math.wrap</code>'s sawtooth. Feed it a frame counter to drive a patrol, a pulsing highlight, or a back-and-forth sweep without tracking direction yourself.
```ludic
program Demo {
handler Step phase Update {
let phase = Math.ping_pong(frame, 30)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-posmod
name: Math.posmod
category: math
kind: namespace-method
tokens: Math.posmod
sig: Math.posmod(a, m) -> int
tip: Modulo that is always non-negative.
order: 20
ns: Math
member: posmod
---
Returns <code>a</code> modulo <code>m</code>, but always in the range <code>0</code>..<code>m-1</code> — unlike the <code>%</code> operator, which keeps the sign of <code>a</code> (so <code>-1 % 5</code> is <code>-1</code>, while <code>Math.posmod(-1, 5)</code> is <code>4</code>). It is what you want for wrapping indices and grid coordinates that can go negative.
```ludic
program Demo {
handler Step phase Update {
let col = Math.posmod(x, grid_width) # never negative
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-rad_to_deg
name: Math.rad_to_deg
category: math
kind: namespace-method
tokens: Math.rad_to_deg
sig: Math.rad_to_deg(radians) -> fixed
tip: Convert radians to degrees.
order: 19
ns: Math
member: rad_to_deg
---
Converts an angle from radians to degrees (multiplying by <code>180/pi</code>) — the inverse of <code>Math.deg_to_rad</code>. Use it to present an internally-radians angle in the degrees a player or designer expects, for a HUD readout or a debug overlay.
```ludic
program Demo {
handler Step phase Update {
let heading_deg = Math.rad_to_deg(heading)
}
}
```

View file

@ -0,0 +1,34 @@
---
id: math-remap
name: Math.remap
category: math
kind: namespace-method
tokens: Math.remap
sig: Math.remap(v, in0, in1, out0, out1) -> fixed
tip: Rescale a value from one range into another.
order: 10
ns: Math
member: remap
---
Maps <code>v</code> from the input range <code>in0</code>..<code>in1</code> onto the output range <code>out0</code>..<code>out1</code>, keeping its relative position — equivalent to <code>Math.lerp(out0, out1, Math.inverse_lerp(in0, in1, v))</code>. All arguments are <code>fixed</code>. It is the one call that converts between units: a health value into a bar width, a noise sample in <code>-1..1</code> into a <code>0..255</code> shade, or a mouse position into a world coordinate. Swap the output ends to invert the mapping.
Parameters:
- `v` — the value to rescale
- `in0` — the start of the input range
- `in1` — the end of the input range
- `out0` — the start of the output range
- `out1` — the end of the output range
```ludic
program HealthBar {
const MAX_HP: fixed = 200.0
const BAR_WIDTH: fixed = 64.0
var hp: fixed = 150.0
handler Draw phase Render {
let width = Math.remap(hp, 0.0, MAX_HP, 0.0, BAR_WIDTH) # 48.0
}
}
```

View file

@ -0,0 +1,27 @@
---
id: math-round
name: Math.round
category: math
kind: namespace-method
tokens: Math.round
sig: Math.round(x) -> int
tip: Round a fixed value to the nearest whole int.
order: 7
ns: Math
member: round
---
Takes a <code>fixed</code> value and returns the nearest <code>int</code>, with halves rounding up: <code>Math.round(2.5)</code> is <code>3</code> and <code>Math.round(2.4)</code> is <code>2</code>. Choose it over <code>Math.floor</code> when you want the closest whole value rather than always the lower one — snapping a smoothly-moving sprite to a pixel, or reporting a fractional total as a clean number.
Parameters:
- `x` — the `fixed` value to round to nearest
```ludic
program SnapToPixel {
var smooth_x: fixed = 10.5
handler Draw phase Render {
let px = Math.round(smooth_x) # 11
}
}
```

View file

@ -0,0 +1,28 @@
---
id: math-sign
name: Math.sign
category: math
kind: namespace-method
tokens: Math.sign
sig: Math.sign(x) -> int
tip: The sign of a value as -1, 0, or 1.
order: 4
ns: Math
member: sign
---
Returns <code>-1</code> when <code>x</code> is negative, <code>1</code> when it is positive, and <code>0</code> when it is exactly zero. It works on both <code>int</code> and <code>fixed</code> values, since zero and sign are the same in Q16.16. Multiply a step by <code>Math.sign(dx)</code> to move one unit toward a target, or read the sign of a velocity to decide which way a sprite faces.
Parameters:
- `x` — the value to test
```ludic
program FaceTarget {
var x: int = 8
var target: int = 20
handler StepToward phase Update {
x = x + Math.sign(target - x) # +1 while target is to the right
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-sin
name: Math.sin
category: math
kind: namespace-method
tokens: Math.sin
sig: Math.sin(radians) -> fixed
tip: Sine of an angle in radians.
order: 12
ns: Math
member: sin
---
Returns the sine of <code>radians</code> as a <code>fixed</code> in <code>-1.0</code>..<code>1.0</code>. It is table-driven (a 256-entry Q16.16 sine table with linear interpolation) so it is fast and fully deterministic. Angles wrap, so any value works — no need to reduce into a range first. Pair it with <code>Math.cos</code> to turn an angle into a direction.
```ludic
program Demo {
handler Step phase Update {
let y = center_y + Math.sin(angle) * radius
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-smoothstep
name: Math.smoothstep
category: math
kind: namespace-method
tokens: Math.smoothstep
sig: Math.smoothstep(e0, e1, x) -> fixed
tip: A smooth 0..1 ramp between two edges.
order: 25
ns: Math
member: smoothstep
---
Returns <code>0.0</code> when <code>x</code> is at or below <code>e0</code>, <code>1.0</code> at or above <code>e1</code>, and a smooth S-curve (ease-in and ease-out) in between — the classic <code>t*t*(3 - 2t)</code> Hermite ramp. Use it for fades, dissolves, and easing a normalized amount before feeding it to <code>Math.lerp</code>, when a straight linear ramp looks too abrupt.
```ludic
program Demo {
handler Step phase Update {
let a = Math.smoothstep(0.0, 1.0, fade_t)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-snapped
name: Math.snapped
category: math
kind: namespace-method
tokens: Math.snapped
sig: Math.snapped(v, step) -> fixed
tip: Round a value to the nearest multiple of step.
order: 23
ns: Math
member: snapped
---
Rounds <code>v</code> to the nearest multiple of <code>step</code> (both <code>fixed</code>) — for snapping a position to a grid, quantizing an angle to 8 directions, or rounding a value to a tidy increment. <code>Math.snapped(x, 16.0)</code> snaps <code>x</code> to a 16-unit grid.
```ludic
program Demo {
handler Step phase Update {
let gx = Math.snapped(world_x, 16.0)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-sqrt
name: Math.sqrt
category: math
kind: namespace-method
tokens: Math.sqrt
sig: Math.sqrt(x) -> fixed
tip: The square root of a fixed value.
order: 11
ns: Math
member: sqrt
---
Returns the non-negative square root of <code>x</code>, computed in Q16.16 by a deterministic integer algorithm — so it is bit-identical on every platform, the same guarantee the rest of the runtime gives. Negative inputs return <code>0</code>. Use it for lengths and distances; when you have two legs of a right triangle reach for <code>Math.hypot</code>, and when you only need to compare magnitudes prefer the cheaper squared distance <code>Math.dist2</code>.
```ludic
program Demo {
handler Step phase Update {
let speed = Math.sqrt(vx * vx + vy * vy)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-tan
name: Math.tan
category: math
kind: namespace-method
tokens: Math.tan
sig: Math.tan(radians) -> fixed
tip: Tangent of an angle in radians.
order: 14
ns: Math
member: tan
---
Returns the tangent of <code>radians</code>, computed as <code>Math.sin(radians) / Math.cos(radians)</code> in fixed-point. Near odd multiples of <code>pi/2</code> the cosine approaches zero and the result grows without bound, so guard those angles. For most gameplay, <code>Math.sin</code>/<code>Math.cos</code> are what you want; <code>tan</code> is here for slopes and field-of-view math.
```ludic
program Demo {
handler Step phase Update {
let slope = Math.tan(pitch)
}
}
```

View file

@ -0,0 +1,22 @@
---
id: math-wrap
name: Math.wrap
category: math
kind: namespace-method
tokens: Math.wrap
sig: Math.wrap(v, lo, hi) -> int
tip: Wrap a value into the range [lo, hi).
order: 21
ns: Math
member: wrap
---
Wraps <code>v</code> into the half-open range <code>lo</code>..<code>hi</code>, so values that run off one end reappear at the other — the toroidal counterpart to <code>Math.clamp</code>. Use it for wrap-around movement, cycling through a menu, or keeping a scrolling offset in bounds.
```ludic
program Demo {
handler Step phase Update {
let x = Math.wrap(player_x, 0, world_width)
}
}
```

View file

@ -0,0 +1,7 @@
---
id: mem
title: Mem
order: 6
---
Raw memory — allocate byte and word buffers, copy and fill regions, and peek/poke individual bytes. The low-level escape hatch.

View file

@ -0,0 +1,14 @@
---
id: mem-bytes
name: Mem.bytes
category: mem
kind: namespace-method
tokens: Mem.bytes
sig: Mem.bytes(n) -> ptr
tip: Allocate n bytes.
order: 0
ns: Mem
member: bytes
---
Allocates an <code>n</code>-byte buffer and returns a pointer to it.

View file

@ -0,0 +1,14 @@
---
id: mem-copy
name: Mem.copy
category: mem
kind: namespace-method
tokens: Mem.copy
sig: Mem.copy(dst, src, n) -> void
tip: Copy n bytes between buffers.
order: 2
ns: Mem
member: copy
---
Copies <code>n</code> bytes from <code>src</code> to <code>dst</code> (regions must not overlap).

View file

@ -0,0 +1,14 @@
---
id: mem-fill
name: Mem.fill
category: mem
kind: namespace-method
tokens: Mem.fill
sig: Mem.fill(buf, value, n) -> void
tip: Set n bytes to a value.
order: 3
ns: Mem
member: fill
---
Sets the first <code>n</code> bytes of <code>buf</code> to the byte <code>value</code>.

View file

@ -0,0 +1,14 @@
---
id: mem-peek
name: Mem.peek
category: mem
kind: namespace-method
tokens: Mem.peek
sig: Mem.peek(buf, i) -> int
tip: Read one byte.
order: 4
ns: Mem
member: peek
---
Returns the byte at <code>buf[i]</code> as an int in 0..255.

View file

@ -0,0 +1,14 @@
---
id: mem-poke
name: Mem.poke
category: mem
kind: namespace-method
tokens: Mem.poke
sig: Mem.poke(buf, i, value) -> void
tip: Write one byte.
order: 5
ns: Mem
member: poke
---
Stores the low byte of <code>value</code> at <code>buf[i]</code>.

View file

@ -0,0 +1,14 @@
---
id: mem-words
name: Mem.words
category: mem
kind: namespace-method
tokens: Mem.words
sig: Mem.words(n) -> words
tip: Allocate n 32-bit words.
order: 1
ns: Mem
member: words
---
Allocates a buffer of <code>n</code> 32-bit integer words, indexable as <code>w[i]</code>.

View file

@ -0,0 +1,7 @@
---
id: net
title: Net
order: 6
---
The low-level networking seam — send and poll datagrams, serialize and apply entity state, and read ownership and role. Offline these collapse to single-player defaults.

View file

@ -0,0 +1,14 @@
---
id: net-apply
name: Net.apply
category: net
kind: namespace-method
tokens: Net.apply
sig: Net.apply(entity, buf) -> int
tip: Apply serialized state to an entity.
order: 3
ns: Net
member: apply
---
Reads synced fields from <code>buf</code> into <code>entity</code> — the inverse of <code>Net.serialize</code>.

View file

@ -0,0 +1,14 @@
---
id: net-is_owner
name: Net.is_owner
category: net
kind: namespace-method
tokens: Net.is_owner
sig: Net.is_owner(entity) -> bool
tip: Does this peer own the entity?
order: 7
ns: Net
member: is_owner
---
Returns true when this peer owns <code>entity</code> — <code>owner(entity) == local_id()</code>.

View file

@ -0,0 +1,14 @@
---
id: net-is_server
name: Net.is_server
category: net
kind: namespace-method
tokens: Net.is_server
sig: Net.is_server() -> bool
tip: Is this peer the server?
order: 6
ns: Net
member: is_server
---
Returns true on the authoritative peer. Offline it is true, so server-only guards run in single-player.

View file

@ -0,0 +1,14 @@
---
id: net-local_id
name: Net.local_id
category: net
kind: namespace-method
tokens: Net.local_id
sig: Net.local_id() -> int
tip: This peer's own id.
order: 8
ns: Net
member: local_id
---
Returns the network id of the local peer.

View file

@ -0,0 +1,14 @@
---
id: net-owner
name: Net.owner
category: net
kind: namespace-method
tokens: Net.owner
sig: Net.owner(entity) -> int
tip: The peer that owns an entity.
order: 4
ns: Net
member: owner
---
Returns the peer id that owns <code>entity</code>'s <code>@Owned</code> state.

View file

@ -0,0 +1,14 @@
---
id: net-poll
name: Net.poll
category: net
kind: namespace-method
tokens: Net.poll
sig: Net.poll(buf, cap) -> int
tip: Read an inbound datagram.
order: 1
ns: Net
member: poll
---
Reads up to <code>cap</code> bytes of the next inbound datagram into <code>buf</code> and returns the byte count, or zero when none is waiting.

View file

@ -0,0 +1,14 @@
---
id: net-send
name: Net.send
category: net
kind: namespace-method
tokens: Net.send
sig: Net.send(peer, buf, len) -> void
tip: Put a datagram on the wire.
order: 0
ns: Net
member: send
---
Sends the first <code>len</code> bytes of <code>buf</code> to <code>peer</code>. Absent a real socket it uses the built-in loopback, so offline code still runs.

View file

@ -0,0 +1,14 @@
---
id: net-serialize
name: Net.serialize
category: net
kind: namespace-method
tokens: Net.serialize
sig: Net.serialize(entity, buf) -> int
tip: Serialize an entity's synced state.
order: 2
ns: Net
member: serialize
---
Writes the <code>@Sync</code> fields of <code>entity</code> into <code>buf</code> and returns the byte count, for sending over the wire.

View file

@ -0,0 +1,14 @@
---
id: net-set_owner
name: Net.set_owner
category: net
kind: namespace-method
tokens: Net.set_owner
sig: Net.set_owner(entity, peer) -> void
tip: Assign ownership of an entity.
order: 5
ns: Net
member: set_owner
---
Sets the owning peer of <code>entity</code> to <code>peer</code>.

View file

@ -3,6 +3,7 @@ id: op-logical
name: Logical
category: operators
kind: operator
tokens: and or not
sig: and or not
tip: The boolean combinators, spelled as words — never && or || or a bare !.
order: 2

View file

@ -0,0 +1,25 @@
---
id: random-int
name: Random.int
category: random
kind: namespace-method
tokens: Random.int
sig: Random.int(max) -> int
tip: A random integer in [0, max).
order: 4
ns: Random
member: int
---
Returns a deterministic integer from <code>0</code> up to but not including <code>max</code>. Use it to pick a random index into a list of length <code>max</code>. Returns 0 when <code>max</code> is not positive.
```ludic
program Demo {
handler Seed phase Start { Random.seed(value: 7) }
handler Step phase Update {
let idx = Random.int(list_count)
}
}
}
}
```

View file

@ -0,0 +1,25 @@
---
id: random-sign
name: Random.sign
category: random
kind: namespace-method
tokens: Random.sign
sig: Random.sign() -> int
tip: A random +1 or -1.
order: 5
ns: Random
member: sign
---
Returns <code>1</code> or <code>-1</code> with equal probability — a deterministic coin flip for choosing a direction or jitter sign.
```ludic
program Demo {
handler Seed phase Start { Random.seed(value: 7) }
handler Step phase Update {
let dir = Random.sign()
}
}
}
}
```

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