chore(repo): DX cleanup — categorise examples, text-diffable golden, build/ output (#27 #28 #30)

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>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-30 18:54:21 +03:00
parent 0bc5d76952
commit fb728bbefe
73 changed files with 364 additions and 194 deletions

View file

@ -0,0 +1,37 @@
# annotations.ludic — the annotation-first style. Behaviour is expressed with
# @-decorators that desugar to plain handlers, queries and expressions, so the
# properties stay pure data and the data-oriented model is untouched.
@Handles(Move)
program RPG2D {
property Transform { x: int = 0, y: int = 0, scale: int = 1 }
property Velocity {
dx: int = 0
dy: int = 0
@Computed speed2: int = dx * dx + dy * dy # derived; no storage, inlined at use
}
model Actor { Transform, Velocity }
# A constructor: every Actor starts visible. Runs at spawn with the model's
# properties bound by name — no per-frame cost, no observer machinery.
@OnSpawn(Actor)
handler InitActor { Transform.scale = 2 }
handler Spawn phase Start {
spawn Actor { Transform { x: 0 } Velocity { dx: 3, dy: 4 } }
spawn Actor { Transform { x: 0 } Velocity { dx: 0, dy: 0 } }
}
# Move every Actor whose scale is positive and that is actually moving. The
# query is the decorator; each bound property is addressed by its own name.
@Queries(these: [Transform{scale > 0}, Velocity{dx > 0 or dy > 0}], on: Actor)
handler Move phase Update {
Transform.x = Transform.x + Velocity.dx
Transform.y = Transform.y + Velocity.dy
}
handler Report phase Render {
for (t, v) in query [Transform, Velocity] { print(t.x); print(v.speed2) }
quit()
}
}

View file

@ -0,0 +1,38 @@
# detach.ludic — the structural attach/detach pair and its @OnAttach / @OnDetach
# hooks. `attach P on e` adds a property to a LIVE entity (seeding its fields and
# firing @OnAttach); `detach P on e` removes it (firing @OnDetach, which still
# reads the outgoing value before the has-flag clears). This is the structural
# counterpart to enable/disable — attach/detach create and destroy the property's
# presence, whereas disable/enable only pause it while keeping the data.
#
# Running it prints: 15 1 25 0
# 15 @OnAttach(Shield): amount seeded to 5, prints 5 + 10
# 1 one live Shield now matches the query
# 25 @OnDetach(Shield): reads the outgoing amount 5, prints 5 + 20
# 0 the Shield is gone — nothing matches
#
# bin/x game-build bin/ludicc examples/lang/detach.ludic /tmp/detach
# /tmp/detach </dev/null
program Detach {
property Tag { v: int = 0 }
property Shield { amount: int = 0 }
model Unit { Tag }
@OnAttach(Shield) handler Up { print(Shield.amount + 10) } # structural: property born
@OnDetach(Shield) handler Down { print(Shield.amount + 20) } # structural: property dies
handler Seed phase Start { spawn Unit { Tag { v: 1 } } }
handler Run phase Render {
for (u) in query [Unit] { attach Shield on self() { amount: 5 } } # @OnAttach -> 15
var n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 1
for (s) in query [Shield] { detach Shield on self() } # @OnDetach -> 25
n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 0
quit()
}
}

View file

@ -0,0 +1,28 @@
# lifecycle.ludic — a game's whole lifecycle as @-hooks. Each fires at one point
# on the timeline and reduces to ordinary code, so nothing is hidden and the
# data-oriented model is untouched. Running it prints: 1 700 50 950 2 — one line
# per hook, in the order the timeline reaches them.
#
# boot ── @OnStart ──▶ spawn ── @OnAttach, @OnSpawn ──▶ … ── @OnDespawn ──▶ quit ── @OnQuit
program Life {
property Health { hp: int = 0, max: int = 100 }
property Sprite { id: int = 0 }
model Enemy { Health, Sprite }
# ---- program lifecycle -----------------------------------------------------
@OnStart handler Boot { print(1) } # once, at boot
@OnQuit handler Bye { print(2) } # once, at shutdown
# ---- property / entity lifecycle -------------------------------------------
@OnAttach(Sprite) handler Load { print(Sprite.id + 700) } # when a Sprite is attached
@OnSpawn(Enemy) handler Init { Health.hp = Health.max } # constructor
@OnDespawn(Enemy) handler Clean { print(Health.hp + 900) } # destructor (dispatched by kind)
handler Seed phase Start { spawn Enemy { Health { max: 50 } } }
handler Run phase Render {
for (h) in query [Health] { print(h.hp) } # 50 — Init set hp to max
for (e) in query [Health] { despawn self() } # Clean fires: 50 + 900 = 950
quit()
}
}

View file

@ -0,0 +1,29 @@
# offline_rewards.ludic — issue #9's worked example: an idle/farming game grants
# "you were away for N hours" rewards on load, using the Time/Date/Duration and
# the deterministic Clock. Everything is integer seconds, so the result replays
# identically — no wall clock, no floating point.
#
# bin/ludic examples/lang/offline_rewards.ludic # prints 13 / 650 / 2026-08-30 / 0
program OfflineRewards {
entry {
# A save records when the player last quit. It is hardcoded here so the demo
# is deterministic; a real game writes Time.now() (or Clock.now()) at save.
let last_seen = DateTime.from(2026, 8, 29, 18, 30, 0)
# On load, set the game clock to "now". We drive it explicitly (rather than
# Time.now) so gameplay stays replay-safe and this sample is reproducible.
Clock.set(DateTime.from(2026, 8, 30, 7, 45, 0))
let away = Clock.now() - last_seen # a Duration in seconds (47700)
let hours = Duration.as_hours(away) # whole hours away
# Reward: 50 coins per full hour, capped at a day's worth.
let capped = Math.min(hours, 24)
let coins = capped * 50
print(hours) # 13
print(coins) # 650
print(DateTime.format(Clock.now(), "YYYY-MM-DD")) # 2026-08-30 (the day they returned)
print(DateTime.weekday(Clock.now())) # 0 = Sunday
}
}

37
examples/lang/qdecl.ludic Normal file
View file

@ -0,0 +1,37 @@
# ============================================================================
# qdecl.ludic — a handler whose query lives in a @Queries annotation.
#
# @Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
# handler CleanBattle phase LateUpdate { … }
#
# is the same thing as writing `for (Battle, Pos) in query [Battle, Pos, {Foe}]
# where Battle.hp <= 0 and Battle.side == 1 { … }` around the whole body — the
# loop header moves into the annotation. Each property binds by name, the body
# runs once per matching entity, and self() is that entity.
# ============================================================================
program QueryDecl {
property Battle { hp: int = 0, side: int = 0 }
property Pos { x: int = 0, y: int = 0 }
model Foe { Battle, Pos }
handler Seed phase Start {
spawn Foe { Battle { hp: 3, side: 1 } }
spawn Foe { Battle { hp: 0, side: 1 } }
spawn Foe { Battle { hp: -2, side: 0 } }
}
# a per-property constraint `Battle{...}` qualifies its bare fields to that
# property; `on: Foe` adds the {Foe} kind filter.
@Queries(these: [Battle{hp <= 0 and side == 1}, Pos], on: Foe)
handler CleanBattle phase LateUpdate {
print(Pos.x)
despawn self()
}
@Queries(these: [Battle])
handler Census phase Render {
print(Battle.hp)
}
handler Bye phase Render { quit() }
}

View file

@ -0,0 +1,38 @@
# reason.ludic — LC1 reason-carrying teardown. One @OnDespawn hook, but it knows
# *why* the entity is ending: `reason: r` binds an EndReason the compiler passes
# at each teardown site. An in-world `despawn` passes EndReason.Despawned; program
# shutdown passes EndReason.Quit (every still-live entity's hook fires at exit — no
# silent deaths). The body branches on the reason, exactly as Unreal's
# EndPlay(reason) / Erlang's terminate(Reason) do.
#
# Running it prints: 503 1009
# 503 Enemy A despawned in-world (Despawned): drop its loot, 3 + 500
# 1009 Enemy B outlived the run; at quit (Quit) it skips loot, 9 + 1000
#
# bin/x game-build bin/ludicc examples/lang/reason.ludic /tmp/reason
# /tmp/reason </dev/null
program Reasons {
property Health { hp: int = 0 }
property Loot { gold: int = 0 }
model Enemy { Health, Loot }
@OnDespawn(Enemy, reason: r) handler Clean {
match r {
EndReason.Quit => { print(Health.hp + 1000) } # app closing — don't bother dropping loot
_ => { print(Loot.gold + 500) } # died in-world — drop the loot
}
}
handler Seed phase Start {
spawn Enemy { Health { hp: 7 }, Loot { gold: 3 } } # A
spawn Enemy { Health { hp: 9 }, Loot { gold: 4 } } # B
}
handler Run phase Render {
var first = 0
for (e) in query [Health] {
if first == 0 { despawn self(); first = 1 } # despawn A -> Despawned -> 3 + 500 = 503
}
quit() # B survives -> Quit -> 9 + 1000 = 1009
}
}

View file

@ -0,0 +1,13 @@
# rng_demo.ludic — exercises the Random.* extensions (value/int/sign) with a
# fixed seed, so the sequence is deterministic for the regression suite.
program RngDemo {
handler Seed phase Start { Random.seed(value: 1) }
handler Once phase Update {
print(Random.int(100))
print(Random.int(100))
print(Math.floor(Random.value() * 10.0))
print(Random.sign())
print(Random.int(0)) # 0 (max <= 0)
quit()
}
}

View file

@ -0,0 +1,48 @@
# scenes — one active scene at a time, each grouping handlers into layers behind
# an implicit active-scene register (see LANGUAGE.md §"Scenes & layers").
#
# Running it (feed a few keystrokes so the loop ticks) prints:
# 1000 1 101 102 2 3 900 201 900 202 900
# 1000 Boot (a global handler) runs once at Start
# 1 Title is `start`; its `on enter` fires at boot
# 101 frame 1 Update: only Title.Main.Tick runs (Play is not active)
# 102 frame 2 Update: Tick reaches 2 -> `become Play`…
# 2 3 …which runs Title's `on exit` then Play's `on enter`
# 900 Play renders the same frame it is entered (Hud.Draw)
# 201 900 frame 3: Play.World.Step, then Hud.Draw
# 202 900 frame 4: Step reaches 2 -> quit(); Hud.Draw paints the last frame
#
# bin/x game-build bin/ludicc examples/lang/scenes.ludic /tmp/scenes
# printf 'aaaa' | /tmp/scenes
program SceneDemo {
var counter: int = 0
handler Boot phase Start { counter = 0; print(1000) }
scene Title start {
on enter { print(1) }
on exit { print(2) }
layer Main {
handler Tick phase Update {
counter = counter + 1
print(100 + counter)
if counter >= 2 { become Play }
}
}
}
scene Play {
on enter { print(3); counter = 0 }
on exit { print(4) }
layer World {
handler Step phase Update {
counter = counter + 1
print(200 + counter)
if counter >= 2 { quit() }
}
}
layer Hud {
handler Draw phase Render { print(900) }
}
}
}

View file

@ -0,0 +1,31 @@
# strings.ludic — strings are values: compare with ==/!=, join with +, or (best)
# interpolate with `backticks {expr}`. Running it prints: 1 2 3 4 5 6 7
program Strings {
entry {
let greeting = "hello"
if greeting == "hello" { print(1) } # content comparison
if greeting != "world" { print(2) }
let who = "Ludic"
let msg = greeting + ", " + who + "!" # concatenation chains
if msg == "hello, Ludic!" { print(3) }
# build up a string in a loop
var line = ""
var i = 0
while i < 3 { line = line + "ab"; i = i + 1 }
if line == "ababab" { print(4) }
if (greeting + who) != greeting { print(5) }
# interpolation: `{expr}` embeds any expression (numbers become text)
let n = 42
if `{greeting}, {who}!` == "hello, Ludic!" { print(6) }
if `n={n}, next={n + 1}` == "n=42, next=43" { print(7) }
# slicing: s[a..b] is the substring of bytes [a, b), and len(s) its length
let full = "hello world"
if full[0..5] == "hello" { print(8) }
if full[6..len(full)] == "world" { print(9) }
}
}

View file

@ -0,0 +1,11 @@
program TimeDemo {
handler Tick phase Update {
let f = Time.frame()
print(f)
if f >= 3 {
print(Math.floor(Time.elapsed() * 100.0))
print(Math.floor(Time.delta() * 1000.0))
quit()
}
}
}

View file

@ -0,0 +1,37 @@
# toggle.ludic — enable/disable at the three ECS scopes, each a plain statement.
# Disabling never destroys data: a property's values persist in storage, so a
# later `enable` restores them. Queries already skip a cleared flag, so nothing
# else in the language needs to know. Running it prints: 6 0 7 1 0
#
# disable P on e clears one entity's has-flag (@OnDisable / @OnEnable fire)
# disable Model flips the model's enabled flag (its entities drop from queries)
# disable Handler flips the handler's enabled flag (it stops running each phase)
program Toggles {
property Health { hp: int = 0, max: int = 100 }
property Shield { amount: int = 0 }
model Player { Health, Shield }
# hooks run at the toggle point with the property's data bound by name
@OnDisable(Shield) handler Down { print(Shield.amount + 1) } # 5 + 1
@OnEnable(Shield) handler Up { print(Shield.amount + 2) } # 5 + 2
handler Seed phase Start { spawn Player { Health { max: 50 }, Shield { amount: 5 } } }
handler Run phase Render {
for (e) in query [Player] { disable Shield on self() } # @OnDisable -> 6
var n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 0 — no live Shield now
for (e) in query [Player] { enable Shield on self() } # @OnEnable -> 7, data intact
n = 0
for (s) in query [Shield] { n += 1 }
print(n) # 1 — amount is still 5
disable Player # whole model off
n = 0
for (p) in query [Player] { n += 1 }
print(n) # 0 — model disabled
quit()
}
}