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