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,16 @@
# ============================================================================
# chronorift.ludic — a 2D windowed co-op JRPG written in Ludic (CC0 Kenney art)
# Overworld + dungeon + random encounters + co-op turn battle + boss + save.
# Compiles to a native macOS app: ludicc -> LLVM IR -> native binary. No C.
#
# This entry file is deliberately thin: the game is split across modules under
# chronorift/, pulled in with `import`. Paths resolve relative to this file, and
# each import is include-guarded so re-imports are free.
# ============================================================================
program ChronoRift {
import "chronorift/world.ludic" # components, entity archetypes, constants
import "chronorift/rules.ludic" # pure rules + map data (functions)
import "chronorift/overworld.ludic" # boot, movement, encounters, shop
import "chronorift/combat.ludic" # co-op turn battle state machine
import "chronorift/draw.ludic" # the Render system
}

View file

@ -0,0 +1,172 @@
# combat.ludic — the co-op turn-based battle state machine.
# The battle is a state machine over the `turn_phase` variable. `machine`
# dispatches on its value and `become` transitions to a named state — no more
# `if ph == N` ladders. Knight turn (0,1), Mage turn (2,3), Enemy turn (4).
# Menu selections, named instead of matched on bare integers (menu_cursor holds one).
enum KnightAct { Attack, Guard, Item, Flee }
enum MageAct { Attack, Heal, Guard }
handler Battle phase Update {
if game_mode == 1 {
let k = Input.key()
for (e) in query [Stats, {Enemy}] {
machine turn_phase {
state KnightMenu {
for (st, pt) in query [Stats, Party] {
if pt.slot == 0 {
if st.hp <= 0 {
become MageMenu
menu_cursor = 0
} else {
if k == 'w' { menu_cursor = max(0, menu_cursor - 1) }
if k == 's' { menu_cursor = min(3, menu_cursor + 1) }
if is_confirm(k) {
match menu_cursor {
KnightAct.Attack => {
let d = max(1, st.atk - e.def + Random.range(0, 4))
e.hp = e.hp - d
player_hit = d
}
KnightAct.Guard => { st.guard = 1; player_hit = 0 }
KnightAct.Item => {
if potions > 0 {
potions = potions - 1
st.hp = min(st.maxhp, st.hp + 24)
player_hit = 24
} else { player_hit = 0 }
}
KnightAct.Flee => {
if Random.chance(50) { e.hp = 0; game_mode = 0 }
else { player_hit = 0 }
}
}
become KnightResolve
menu_cursor = 0
}
}
}
}
}
state KnightResolve {
if k != 0 {
if e.hp <= 0 {
grant_xp(e.xp)
gold = gold + e.xp
if enemy_type == 2 { boss_down = 1; game_mode = 3; ack = 0 }
else { game_mode = 0 }
become KnightMenu
} else {
become MageMenu
menu_cursor = 0
}
}
}
state MageMenu {
for (st, pt) in query [Stats, Party] {
if pt.slot == 1 {
if st.hp <= 0 {
become EnemyTurn
} else {
if k == 'i' { menu_cursor = max(0, menu_cursor - 1) }
if k == 'k' { menu_cursor = min(2, menu_cursor + 1) }
if k == 'j' {
match menu_cursor {
MageAct.Attack => {
let d = max(1, st.atk + 5 - e.def + Random.range(0, 5))
e.hp = e.hp - d
player_hit = d
}
MageAct.Heal => {
if st.mp >= 4 {
st.mp = st.mp - 4
for (t2, p2) in query [Stats, Party] {
if p2.slot == 0 { t2.hp = min(t2.maxhp, t2.hp + 16) }
}
player_hit = 16
} else { player_hit = 0 }
}
MageAct.Guard => { st.guard = 1; player_hit = 0 }
}
become MageResolve
menu_cursor = 0
}
}
}
}
}
state MageResolve {
if k != 0 {
if e.hp <= 0 {
grant_xp(e.xp)
gold = gold + e.xp
if enemy_type == 2 { boss_down = 1; game_mode = 3; ack = 0 }
else { game_mode = 0 }
become KnightMenu
} else {
become EnemyTurn
}
}
}
state EnemyTurn {
if k != 0 {
var done = 0
for (t3) in query [Stats, {Party}] {
if done == 0 {
if t3.hp > 0 {
var ed = max(1, e.atk - t3.def + Random.range(0, 4))
if t3.guard == 1 { ed = ed / 2; t3.guard = 0 }
t3.hp = t3.hp - ed
enemy_hit = ed
done = 1
}
}
}
var alive = 0
for (t4) in query [Stats, {Party}] {
if t4.hp > 0 { alive = alive + 1 }
}
if alive == 0 { game_mode = 2; ack = 0 }
else {
become KnightMenu
menu_cursor = 0
}
}
}
}
}
}
}
handler CleanBattle phase LateUpdate {
if game_mode != 1 {
for (e) in query [Stats, {Enemy}] {
despawn self()
}
}
}
handler Meta phase Update {
let k = Input.key()
if game_mode == 2 {
if k == 0 { ack = 1 }
if k != 0 {
if ack == 1 {
for (h) in query [Stats, {Party}] { h.hp = h.maxhp }
game_mode = 0
}
}
}
if game_mode == 3 {
if k == 0 { ack = 1 }
if k != 0 {
if ack == 1 { game_mode = 0 }
}
}
}

View file

@ -0,0 +1,138 @@
# draw.ludic — the Render system (overworld, battle, and overlay screens).
handler Draw phase Render {
let m = game_mode
Screen.clear(0x101018)
if m == 0 {
var cx = 0
var cy = 0
for (p) in query [Pos, {Player}] {
cx = clamp(p.x * 16 - 152, 0, MW * 16 - 320)
cy = clamp(p.y * 16 - 112, 0, MH * 16 - 240)
}
let tx0 = cx / 16
let ty0 = cy / 16
for ty in ty0 .. ty0 + 16 {
for tx in tx0 .. tx0 + 21 {
let c = Map.tile(tx, ty)
let sx = tx * 16 - cx
let sy = ty * 16 - cy
var base = SPR_GRASS
match c {
'r' => base = SPR_PATH
',', 'S', 'B' => base = SPR_FLOOR
'=' => base = SPR_WALL
}
draw_sprite(base, sx, sy)
if c == 'T' { draw_sprite(SPR_TREE, sx, sy) }
if c == '#' { draw_sprite(SPR_TREE, sx, sy) }
if c == 'H' { draw_sprite(SPR_HOUSE, sx, sy) }
if c == 'D' { draw_sprite(SPR_DOOR, sx, sy) }
if c == 'S' { draw_sprite(SPR_DOOR, sx, sy) }
if c == 'B' {
if boss_down == 0 { draw_sprite(SPR_BOSS, sx, sy) }
}
}
}
for (p, a) in query [Pos, Actor] {
draw_sprite(a.kind, p.x * 16 - cx, p.y * 16 - cy)
}
for (h, pp) in query [Stats, Party] {
if pp.slot == 0 {
Screen.fill_rectangle(4, 4, 96, 38, 0x0c0c16)
Screen.draw_rectangle(4, 4, 96, 38, 0x3a3a55)
Screen.draw_text(8, 8, "LV", 0xffe08a, 1)
Screen.draw_number(24, 8, h.lvl, 0xffffff, 1)
Screen.draw_text(8, 18, "HP", 0x88ff88, 1)
Screen.draw_number(24, 18, h.hp, 0xffffff, 1)
Screen.draw_text(8, 28, "G", 0xffd24a, 1)
Screen.draw_number(18, 28, gold, 0xffffff, 1)
Screen.draw_text(54, 28, "PO", 0x88ffcc, 1)
Screen.draw_number(72, 28, potions, 0xffffff, 1)
}
}
}
if m == 1 {
Screen.fill_rectangle(0, 0, 320, 176, 0x241a2e)
for (e, a) in query [Stats, Actor, {Enemy}] {
var bs = 5
if enemy_type == 2 { bs = 7 }
draw_sprite_scaled(a.kind, 160 - bs * 8, 44, bs)
Screen.draw_text(100, 20, enemy_name(enemy_type), 0xffffff, 1)
Screen.fill_rectangle(100, 30, 120, 8, 0x401018)
Screen.fill_rectangle(100, 30, e.hp * 120 / e.maxhp, 8, 0xe04040)
}
let ph = turn_phase
var act = 0
if ph >= 2 { act = 1 }
if ph >= 4 { act = 2 }
Screen.fill_rectangle(0, 176, 320, 64, 0x0c0c16)
Screen.draw_rectangle(0, 176, 320, 64, 0x3a3a55)
for (st, pt) in query [Stats, Party] {
let hx = 6 + pt.slot * 92
var spr = 5
if pt.slot == 1 { spr = 9 }
if pt.slot == act { Screen.draw_rectangle(hx - 2, 180, 88, 44, 0xffff00) }
draw_sprite_scaled(spr, hx, 184, 2)
if pt.slot == 0 { Screen.draw_text(hx + 36, 182, "KNIGHT", 0xffe08a, 1) }
if pt.slot == 1 { Screen.draw_text(hx + 36, 182, "MAGE", 0xc9a0ff, 1) }
Screen.draw_text(hx + 36, 195, "HP", 0x88ff88, 1)
Screen.draw_number(hx + 56, 195, st.hp, 0xffffff, 1)
Screen.draw_text(hx + 36, 207, "MP", 0x88aaff, 1)
Screen.draw_number(hx + 56, 207, st.mp, 0xffffff, 1)
}
let cur = menu_cursor
if ph == 0 or ph == 1 {
Screen.draw_text(206, 181, "P1 KNIGHT", 0xffe08a, 1)
Screen.draw_text(214, 192, "ATTACK", 0xffffff, 1)
Screen.draw_text(214, 203, "DEFEND", 0xffffff, 1)
Screen.draw_text(214, 214, "ITEM", 0xffffff, 1)
Screen.draw_text(256, 214, "x", 0x777777, 1)
Screen.draw_number(264, 214, potions, 0xffd24a, 1)
Screen.draw_text(214, 225, "RUN", 0xffffff, 1)
if ph == 0 { Screen.draw_text(204, 192 + cur * 11, ">", 0xffff00, 1) }
}
if ph == 2 or ph == 3 {
Screen.draw_text(206, 182, "P2 MAGE", 0xc9a0ff, 1)
Screen.draw_text(214, 196, "ATTACK", 0xffffff, 1)
Screen.draw_text(214, 208, "HEAL", 0xffffff, 1)
Screen.draw_text(214, 220, "DEFEND", 0xffffff, 1)
if ph == 2 { Screen.draw_text(204, 196 + cur * 12, ">", 0xffff00, 1) }
}
if ph == 4 { Screen.draw_text(206, 200, "ENEMY TURN", 0xff8888, 1) }
if ph == 1 or ph == 3 {
Screen.draw_text(6, 232, "DMG", 0xffff88, 1)
Screen.draw_number(34, 232, player_hit, 0xffffff, 1)
}
if ph == 4 {
Screen.draw_text(6, 232, "FOE HITS", 0xff8888, 1)
Screen.draw_number(66, 232, enemy_hit, 0xffffff, 1)
}
}
if m == 2 {
Screen.draw_text(112, 104, "YOU FELL", 0xff5555, 2)
Screen.draw_text(84, 140, "PRESS ANY KEY", 0xffffff, 1)
}
if m == 3 {
Screen.draw_text(60, 96, "THE RIFT IS SEALED", 0x88ffcc, 2)
Screen.draw_text(96, 132, "VICTORY!", 0xffe060, 3)
Screen.draw_text(84, 170, "PRESS ANY KEY", 0xffffff, 1)
}
if m == 4 {
Screen.fill_rectangle(60, 60, 200, 120, 0x141422)
Screen.draw_rectangle(60, 60, 200, 120, 0x6a6a95)
Screen.draw_text(96, 72, "ITEM SHOP", 0xffe060, 2)
Screen.draw_text(80, 104, "BUY POTION", 0xffffff, 1)
Screen.draw_text(180, 104, "10 G", 0xffd24a, 1)
Screen.draw_text(80, 122, "LEAVE", 0xffffff, 1)
Screen.draw_text(70, 104 + menu_cursor * 18, ">", 0xffff00, 1)
Screen.draw_text(80, 150, "GOLD", 0xffd24a, 1)
Screen.draw_number(120, 150, gold, 0xffffff, 1)
Screen.draw_text(160, 150, "POTIONS", 0x88ffcc, 1)
Screen.draw_number(226, 150, potions, 0xffffff, 1)
}
Screen.show()
}

View file

@ -0,0 +1,140 @@
# overworld.ludic — boot, movement/encounter input, and the shop system.
handler Boot phase Start {
png_load("assets/kenney/tiny-town/Tiles/tile_0000.png") # 0 grass
png_load("assets/kenney/tiny-town/Tiles/tile_0004.png") # 1 tree
png_load("assets/kenney/tiny-town/Tiles/tile_0025.png") # 2 path
png_load("assets/kenney/tiny-town/Tiles/tile_0073.png") # 3 house
png_load("assets/kenney/tiny-town/Tiles/tile_0112.png") # 4 arch
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0096.png") # 5 hero
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0108.png") # 6 slime
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0120.png") # 7 skeleton
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0110.png") # 8 boss
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0084.png") # 9 mage
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0048.png") # 10 floor
png_load("assets/kenney/tiny-dungeon/Tiles/tile_0026.png") # 11 wall
build_field(0)
spawn Player {
Pos { x: 11, y: 15 }
Actor { kind: 5 }
Party { slot: 0 }
Stats { hp: 40, maxhp: 40, mp: 8, maxmp: 8, atk: 12, def: 6, lvl: 1, xp: 0, guard: 0 }
}
spawn Ally {
Party { slot: 1 }
Stats { hp: 26, maxhp: 26, mp: 16, maxmp: 16, atk: 8, def: 4, lvl: 1, xp: 0, guard: 0 }
}
game_mode = 0
encounter = 4
world_map = 0
boss_down = 0
gold = 20
potions = 2
}
handler Control phase Input {
if game_mode == 0 {
for (p) in query [Pos, {Player}] {
let k = Input.key()
var nx = p.x
var ny = p.y
if k == 'w' { ny = p.y - 1 }
if k == 's' { ny = p.y + 1 }
if k == 'a' { nx = p.x - 1 }
if k == 'd' { nx = p.x + 1 }
var moved = 0
if walkable(nx, ny) {
if nx != p.x or ny != p.y {
p.x = nx
p.y = ny
moved = 1
}
}
if moved == 1 {
let tc = Map.tile(p.x, p.y)
if tc == 'D' {
build_dungeon(0)
p.x = 3
p.y = 2
world_map = 1
}
if tc == 'S' {
build_field(0)
p.x = 36
p.y = 3
world_map = 0
}
if tc == 'B' {
if boss_down == 0 {
enemy_type = 2
spawn Enemy {
Actor { kind: 8 }
Stats { hp: 120, maxhp: 120, mp: 0, maxmp: 0, atk: 18, def: 10, lvl: 5, xp: 300, guard: 0 }
}
game_mode = 1
turn_phase = 0
menu_cursor = 0
}
}
if tc == 'h' {
game_mode = 4
menu_cursor = 0
}
if tc != 'D' {
if tc != 'S' {
if tc != 'B' {
if tc != 'h' {
encounter = encounter - 1
if encounter <= 0 {
encounter = Random.range(4, 9)
var et = 0
if world_map == 1 { et = 1 }
if world_map == 0 {
if Random.chance(40) { et = 1 }
}
enemy_type = et
if et == 0 {
spawn Enemy {
Actor { kind: 6 }
Stats { hp: 18, maxhp: 18, mp: 0, maxmp: 0, atk: 8, def: 2, lvl: 1, xp: 7, guard: 0 }
}
}
if et == 1 {
spawn Enemy {
Actor { kind: 7 }
Stats { hp: 30, maxhp: 30, mp: 0, maxmp: 0, atk: 11, def: 5, lvl: 1, xp: 15, guard: 0 }
}
}
game_mode = 1
turn_phase = 0
menu_cursor = 0
}
}
}
}
}
}
if k == 'k' { save() }
if k == 'l' { if load() { } }
}
}
}
# shop (at the house 'h' tile): buy potions with gold
handler Shop phase Update {
if game_mode == 4 {
let k = Input.key()
if k == 'w' { menu_cursor = max(0, menu_cursor - 1) }
if k == 's' { menu_cursor = min(1, menu_cursor + 1) }
if is_confirm(k) {
if menu_cursor == 0 {
if gold >= 10 {
gold = gold - 10
potions = potions + 1
}
}
if menu_cursor == 1 { game_mode = 0 }
}
}
}

View file

@ -0,0 +1,86 @@
# rules.ludic — pure game rules & map data (functions only, no systems).
function tile_sprite(c: int) -> int {
match c {
'T', '#' => return SPR_TREE
'H' => return SPR_HOUSE
'D' => return SPR_DOOR
'r', 'h' => return SPR_PATH
',', 'S', 'B' => return SPR_FLOOR
'=' => return SPR_WALL
}
return SPR_GRASS
}
function walkable(x: int, y: int) -> bool {
let c = Map.tile(x, y)
return c == '.' or c == 'D' or c == 'r' or c == ',' or c == 'S' or c == 'B' or c == 'h'
}
function is_confirm(k: int) -> bool { return k == 32 or k == 10 or k == 13 }
function enemy_name(et: int) -> string {
if et == 0 { return "SLIME" }
if et == 1 { return "SKELETON" }
return "RIFT WARDEN"
}
function build_field(x: int) -> int {
Map.size(40, 18)
Map.row(0, "########################################")
Map.row(1, "#......................................#")
Map.row(2, "#...TT.........................TT.....D#")
Map.row(3, "#..TT.......................TT.........#")
Map.row(4, "#..T.........................T.........#")
Map.row(5, "#....................................T.#")
Map.row(6, "#.....TT....................TT.......T.#")
Map.row(7, "#....TT.....................T..........#")
Map.row(8, "#...........rrrrrrrr........HH.........#")
Map.row(9, "#..........r........r.......HH.........#")
Map.row(10, "#......TT...r........rrrrrrrrhr......T.#")
Map.row(11, "#.....TT...r.................r.......T.#")
Map.row(12, "#..........r.......TT.......r..........#")
Map.row(13, "#..........rrrrrrrrrrrrrrrrr.........T.#")
Map.row(14, "#.........TT.........................T.#")
Map.row(15, "#........TT..........................T.#")
Map.row(16, "#....................................T.#")
Map.row(17, "########################################")
return 0
}
function build_dungeon(x: int) -> int {
Map.size(40, 18)
Map.row(0, "========================================")
Map.row(1, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(2, "=,S,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(3, "=,,,,,====,,,,,,,,,,,,,,,====,,,,,,,,,,=")
Map.row(4, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(5, "=,,,,,,,,,,,,,====,,,,,,,,,,,,,,,,,,,,,=")
Map.row(6, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,====,,,,,=")
Map.row(7, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(8, "=,,,====,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(9, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(10, "=,,,,,,,,,,,,,,,,,,====,,,,,,,,,,,,,,,,=")
Map.row(11, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(12, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,====,,,,,,,=")
Map.row(13, "=,,,,,,,,,====,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(14, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(15, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,B,,=")
Map.row(16, "=,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,=")
Map.row(17, "========================================")
return 0
}
function grant_xp(amount: int) -> int {
for (t) in query [Stats, {Party}] {
if t.hp > 0 {
t.xp = t.xp + amount
while t.xp >= t.lvl * 20 {
t.xp = t.xp - t.lvl * 20
t.lvl = t.lvl + 1
t.maxhp = t.maxhp + 8
t.hp = t.maxhp
t.maxmp = t.maxmp + 2
t.atk = t.atk + 2
t.def = t.def + 1
}
}
}
return 0
}

View file

@ -0,0 +1,43 @@
# world.ludic — the data model: components, entity archetypes, constants.
# Imported by examples/games/chronorift.ludic. No `game` wrapper (this is a fragment).
property Pos { x: int = 0, y: int = 0 }
property Actor { kind: int = 0 }
property Stats { hp: int = 1, maxhp: int = 1, mp: int = 0, maxmp: int = 0,
atk: int = 1, def: int = 0, lvl: int = 1, xp: int = 0, guard: int = 0 }
property Party { slot: int = 0 }
# Entity KINDS. Identity is stored as one integer per entity (L_kind), not as
# an empty marker component. Query membership with {Player} / {Enemy}.
model Player { Pos, Actor, Party, Stats } # the controllable knight
model Ally { Party, Stats } # backline mage (no overworld body)
model Enemy { Actor, Stats } # battle foe
const MW: int = 40
const MH: int = 18
const SPR_GRASS: int = 0
const SPR_TREE: int = 1
const SPR_PATH: int = 2
const SPR_HOUSE: int = 3
const SPR_DOOR: int = 4
const SPR_HERO: int = 5
const SPR_SLIME: int = 6
const SPR_SKEL: int = 7
const SPR_BOSS: int = 8
const SPR_MAGE: int = 9
const SPR_FLOOR: int = 10
const SPR_WALL: int = 11
var game_mode: int = 0 # 0 field, 1 battle, 2 gameover, 3 victory
var turn_phase: int = 0
var menu_cursor: int = 0
var encounter: int = 0
var enemy_type: int = 0 # 0 slime, 1 skeleton, 2 boss
var player_hit: int = 0
var enemy_hit: int = 0
var gold: int = 0
var world_map: int = 0 # 0 overworld, 1 dungeon
var boss_down: int = 0 # boss defeated flag
var ack: int = 0 # debounce for end screens
var potions: int = 0 # potions in inventory

40
examples/games/menu.ludic Normal file
View file

@ -0,0 +1,40 @@
# menu.ludic — a retained-UI title screen. The UI is declared as DATA (the `ui`
# block); the engine lays it out (stacked panel, padding/gap/alignment), draws
# it (background, TrueType labels, focusable buttons), and routes keyboard focus
# and activation. The game only loads a font, opens the UI, and reads clicks.
program Menu {
var title_font: int = 0
ui MainMenu {
panel id: Root w: 288 pad: 16 gap: 6 bg: 0x1a1a2c border: 0x4a4a72 skin: "assets/ui/panel.png" inset: 10 align: center {
label text: "CHRONO RIFT" font: title_font size: 26 fg: 0xffe060 align: center
label text: "a Ludic retained-UI demo" font: title_font size: 14 fg: 0x9aa0c8 align: center
spacer h: 4
button id: NewGame text: "New Game" font: title_font size: 16 w: 236
button id: Continue text: "Continue" font: title_font size: 16 w: 236
button id: Options text: "Options" font: title_font size: 16 w: 236
button id: Quit text: "Quit" font: title_font size: 16 w: 236
}
}
handler Boot phase Start {
title_font = font_load("/System/Library/Fonts/Supplemental/Arial.ttf")
ui_build()
ui_open(UI_MainMenu)
}
handler Nav phase Update {
ui_tick(Input.key())
if ui_clicked(UI_NewGame) { ui_set_text(UI_NewGame, "Starting...") }
if ui_clicked(UI_Continue) { ui_set_text(UI_Continue, "No save") }
if ui_clicked(UI_Options) { ui_set_text(UI_Options, "Soon (TM)") }
if ui_clicked(UI_Quit) { quit() }
}
handler Draw phase Render {
Screen.clear(0x0e0e16)
ui_render()
Screen.show()
}
}

163
examples/games/snake.ludic Normal file
View file

@ -0,0 +1,163 @@
# ============================================================================
# snake.ludic — a small, complete game in Ludic. No sprites or assets: every
# pixel is drawn from primitives. Build: bin/x app examples/games/snake.ludic
#
# It shows the everyday shape of a Ludic game: an ECS for the moving parts (one
# entity per snake segment), named program state for the rest, and a Render
# handler that paints the frame with the Screen API and named colors.
# ============================================================================
program Snake {
property Pos { x: int = 0, y: int = 0 }
property Seg { order: int = 0 } # 0 = head, 1.. = body toward the tail
const GRID_W: int = 20 # playfield, in tiles
const GRID_H: int = 15
const TILE: int = 16 # pixels per tile
const DIR_UP: int = 0
const DIR_DOWN: int = 1
const DIR_LEFT: int = 2
const DIR_RIGHT: int = 3
const MODE_PLAY: int = 0
const MODE_OVER: int = 1
# ---- the game's whole state, named ----------------------------------------
var direction: int = DIR_RIGHT
var length: int = 3
var tick: int = 0
var food_x: int = 14
var food_y: int = 7
var mode: int = MODE_PLAY
var score: int = 0
var ate: int = 0 # did the head reach the food this step?
var tail_x: int = 0 # where the tail sat before it moved (grow here)
var tail_y: int = 0
var acked: int = 0 # game-over key must be released before restart
# ---- custom shades this game uses (values the named palette doesn't carry) -
const SHADE_BG: int = 0x0d1020 # backdrop behind the board
const SHADE_TILE_A: int = 0x141a2e # checkerboard, light square
const SHADE_TILE_B: int = 0x10162a # checkerboard, dark square
const SHADE_FOOD: int = 0xe0405a
const SHADE_BODY: int = 0x40c060
const SHADE_HEAD: int = 0x9dffb0
const SHADE_SCORE: int = 0xffe060
const SHADE_OVER: int = 0xff5555
function reset(unused: int) -> int {
for (p, s) in query [Pos, Seg] { despawn self() }
spawn S0 { Seg { order: 0 }; Pos { x: 10, y: 7 } }
spawn S1 { Seg { order: 1 }; Pos { x: 9, y: 7 } }
spawn S2 { Seg { order: 2 }; Pos { x: 8, y: 7 } }
direction = DIR_RIGHT
length = 3
tick = 0
mode = MODE_PLAY
score = 0
food_x = 14
food_y = 7
return 0
}
handler Boot phase Start { reset(0) }
handler Input phase Input {
let k = Input.key()
if mode == MODE_PLAY {
if k == 'w' { if direction != DIR_DOWN { direction = DIR_UP } }
if k == 's' { if direction != DIR_UP { direction = DIR_DOWN } }
if k == 'a' { if direction != DIR_RIGHT { direction = DIR_LEFT } }
if k == 'd' { if direction != DIR_LEFT { direction = DIR_RIGHT } }
}
if mode == MODE_OVER {
if k == 0 { acked = 1 }
if k != 0 { if acked == 1 { reset(0) } }
}
}
handler Move phase Update {
if mode == MODE_PLAY {
tick = tick + 1
if tick >= 6 {
tick = 0
# remember the tail cell, then drag every segment onto the one ahead of it
for (pt, stg) in query [Pos, Seg] {
if stg.order == length - 1 { tail_x = pt.x; tail_y = pt.y }
}
for step in 0 .. length {
let i = length - 1 - step
if i >= 1 {
for (pa, sa) in query [Pos, Seg] {
if sa.order == i {
for (pb, sb) in query [Pos, Seg] {
if sb.order == i - 1 { pa.x = pb.x; pa.y = pb.y }
}
}
}
}
}
# move the head, check the walls, check the food
ate = 0
for (ph, sh) in query [Pos, Seg] {
if sh.order == 0 {
if direction == DIR_UP { ph.y = ph.y - 1 }
if direction == DIR_DOWN { ph.y = ph.y + 1 }
if direction == DIR_LEFT { ph.x = ph.x - 1 }
if direction == DIR_RIGHT { ph.x = ph.x + 1 }
if ph.x < 0 { mode = MODE_OVER }
if ph.x >= GRID_W { mode = MODE_OVER }
if ph.y < 0 { mode = MODE_OVER }
if ph.y >= GRID_H { mode = MODE_OVER }
if ph.x == food_x { if ph.y == food_y { ate = 1 } }
}
}
# self-collision: head onto any body segment
for (ph, sh) in query [Pos, Seg] {
if sh.order == 0 {
for (pb, sb) in query [Pos, Seg] {
if sb.order > 0 {
if pb.x == ph.x { if pb.y == ph.y { mode = MODE_OVER } }
}
}
}
}
if ate == 1 {
score = score + 1
spawn Body { Seg { order: length }; Pos { x: tail_x, y: tail_y } }
length = length + 1
food_x = Random.range(low: 0, high: GRID_W - 1)
food_y = Random.range(low: 0, high: GRID_H - 1)
}
}
}
}
handler Draw phase Render {
Screen.clear(SHADE_BG)
# checkerboard field
for gy in 0 .. GRID_H {
for gx in 0 .. GRID_W {
var tile_color = SHADE_TILE_A
if (gx + gy) % 2 == 0 { tile_color = SHADE_TILE_B }
Screen.fill_rectangle(x: gx * TILE, y: gy * TILE, width: TILE, height: TILE, color: tile_color)
}
}
# food, inset a few pixels inside its tile
Screen.fill_rectangle(x: food_x * TILE + 3, y: food_y * TILE + 3, width: TILE - 6, height: TILE - 6, color: SHADE_FOOD)
# every snake segment, straight from the ECS
for (p, s) in query [Pos, Seg] {
var seg_color = SHADE_BODY
if s.order == 0 { seg_color = SHADE_HEAD } # the head is brighter
Screen.fill_rectangle(x: p.x * TILE + 1, y: p.y * TILE + 1, width: TILE - 2, height: TILE - 2, color: seg_color)
}
Screen.draw_text(x: 6, y: 4, text: "SCORE", color: Color.White, scale: 1)
Screen.draw_number(x: 52, y: 4, value: score, color: SHADE_SCORE, scale: 1)
if mode == MODE_OVER {
Screen.draw_text(x: 112, y: 104, text: "GAME OVER", color: SHADE_OVER, scale: 2)
Screen.draw_text(x: 84, y: 140, text: "PRESS ANY KEY", color: Color.White, scale: 1)
}
Screen.show()
}
}