709 lines
87 KiB
Markdown
709 lines
87 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to the Ludic toolchain, newest first. Each section is
|
||
generated from the changesets under changes/ by `ludic dev release`, grouped by
|
||
change type. Preview the next one with `ludic dev release --dry-run`.
|
||
|
||
A released section may carry a hand-written summary paragraph above its groups
|
||
(v0.2.0 has one); the generated bullets below it are not edited by hand.
|
||
|
||
## v0.10.0 — 2026-09-11
|
||
|
||
### Features
|
||
|
||
- **`.packignore`** — keep build artefacts out of the shipped asset pack.
|
||
|
||
A pack root is packed wholesale, so everything a model or texture pipeline leaves
|
||
beside its output ships too: preview renders, bake intermediates, the `.blend` a
|
||
`.gltf` came from. Nothing errors and nothing looks wrong — the app is just bigger
|
||
than the game. The only way out was naming every file in `package.ludic`, a list
|
||
that goes stale the day someone adds a texture.
|
||
|
||
Put a `.packignore` beside the assets instead. The rules are **gitignore's**, down
|
||
to the parts people rely on without thinking about them: patterns anchored by a
|
||
slash or floating without one, `preview/` for directories only, `*` and `?` stopping
|
||
at a separator where `**` crosses it, `[a-z]` classes, `!` re-includes with the last
|
||
line winning, a deeper file beating a shallower one, and no re-including out of an
|
||
ignored directory.
|
||
|
||
`ludic pack` reports what it left out, and `--no-ignore` packs everything so you can
|
||
see what a rule costs. `ludic bundle` gathers the same way. `.packignore` itself is
|
||
never packed, and the file only governs your own roots — a package's resources, such
|
||
as the renderer's shaders, are added afterwards and cannot be excluded by accident.
|
||
## v0.9.1 — 2026-09-11
|
||
|
||
### Fixes
|
||
|
||
- **`outline_model`'s batch survives the whole frame.** A frame is drawn by more than one
|
||
pass — a shadow map, a water reflection, the scene — and `actor_draw` runs in each of
|
||
them. An Actor's rim survives that because it is a field on the actor; the queue did not,
|
||
because the first pass to run emptied it, so a queued outline was drawn into whichever
|
||
target happened to come first and was gone by the time the scene was drawn. Nothing
|
||
appeared, with every uniform, matrix and mesh correct.
|
||
|
||
A flush now *closes* the batch rather than clearing it, and the next `outline_model`
|
||
opens a new one: every pass in a frame sees the same requests, and a caller still needs
|
||
no frame hook.
|
||
## v0.9.0 — 2026-09-11
|
||
|
||
### Features
|
||
|
||
- **`Audio.play_at(id, gain:, pitch:, pan:)`** — fire a one-shot with its own gain, pitch
|
||
and stereo position, leaving the master settings alone.
|
||
|
||
`Audio.volume` and `Audio.pitch` are global: they are there so a player can turn the game
|
||
down, and a game that used them to place a sound in the world would be fighting its own
|
||
options screen. This is the per-voice version, and it is what distance attenuation is made
|
||
of — a 3D game works out how far away a sound is and which side it is on, and says so.
|
||
`gain` rides on top of the master volume, so the options screen still wins; `pan` runs
|
||
-1 (hard left) to 1 (hard right).
|
||
|
||
A loaded sound is still one player, so firing the same handle again restarts it rather
|
||
than layering a second copy. Load a handle per variant when several need to overlap.
|
||
- **`ludic.render3d`: `outline_model(model, mat, width, r, g, b)`** — a rim around something
|
||
that is not an Actor.
|
||
|
||
An Actor has had an `outline` since the outline pass landed, and everything else in a
|
||
scene had nothing: the pass walked the actor list and stopped. Instanced scatter was the
|
||
gap that mattered, because a game that highlights whatever the crosshair is on could
|
||
highlight every object in the world *except* the twenty thousand most common ones — the
|
||
trees.
|
||
|
||
`outline_model` queues a model at a transform from anywhere in the frame and the outline
|
||
pass flushes it alongside the actors', which is what gets the depth test right: the rim has
|
||
to be drawn after the scene it is tested against, and a caller does not control pass order.
|
||
A layer whose vertex shader moves its instances — wind, or standing them on the drawn
|
||
terrain — should be handed the transform that shader arrives at.
|
||
- **`ludic.render3d`: `terrain_coast(cx, cz, margin, fall)`** — the other way to make an
|
||
island, and the one a real survey wants: the sea goes around the survey's **own edge**
|
||
rather than being cut out of the middle of it.
|
||
|
||
`terrain_island` measures a radius out from a centre, which suits a made-up map and
|
||
drowns most of a real one — an 8 km mountain survey loses two thirds of itself to make an
|
||
island of the rest. `terrain_coast` measures inward from the boundary instead: everything
|
||
the data covers stays land, the outer `margin` metres go under water, and the `fall` metres
|
||
inside that are scaled down into it. The band is wobbled by low-frequency noise, so what
|
||
comes out is headlands and bays rather than the square the data arrived in.
|
||
|
||
Both modes also gained a **strand**. Scaling alone hands a 500 m mountainside a 40-degree
|
||
plunge into the sea, which is a cliff coast and nothing else; the last few metres of height
|
||
either side of the water line are now compressed, which stretches them out horizontally
|
||
into beach and shallows. Inland lakes are untouched — they have their own bed.
|
||
## v0.8.0 — 2026-09-10
|
||
|
||
### Features
|
||
|
||
- **A boot splash**, customised by the game. `app splash` in `package.ludic` names
|
||
the artwork and `app splash_bg` the colour behind it; the runtime raises a
|
||
borderless window from a constructor that runs before `main`, reading the image
|
||
out of the asset pack, so it is on screen while the process is still starting
|
||
rather than after the slow part it exists to cover. Nothing hides it
|
||
automatically - only the game knows when its first real frame is ready, so the
|
||
game calls `App.splash_hide()`.
|
||
- **Asset packs.** `ludic pack` writes every asset a game opens into one `.lpak`
|
||
beside the binary, and the runtime mounts it before `main`. Nothing about how a
|
||
game is written changes: the pack is spliced in at `file_open`, the one place
|
||
every asset comes through, so `gltf_load`, `tex_load`, `Audio.load`,
|
||
`Fs.read_text` and the renderer's own shader loads all find it without knowing
|
||
it exists. `Fs.exists` and `Fs.size` consult the packs too. Entries are stored
|
||
rather than compressed and the pack is `mmap`'d, so 165 MB of terrain costs one
|
||
syscall at startup and pages in only what is touched. Several packs can be
|
||
mounted in order, and a later one shadows an earlier one - which is how a patch
|
||
replaces individual files without rewriting the base pack. See `docs/SHIPPING.md`.
|
||
- **`ludic bundle`** turns a built game into a real macOS `.app`: `Info.plist` and
|
||
`PkgInfo` from the manifest, an `.icns` built from one source PNG at every size
|
||
macOS asks for, the asset pack in `Contents/Resources`, and an ad-hoc signature
|
||
so it launches on Apple silicon. Everything comes from `app` lines in
|
||
`package.ludic`, so the command takes no arguments. A bundled game is also moved
|
||
to `~/Library/Application Support/<name>` at startup, because Finder starts a
|
||
`.app` with its working directory at `/` where no save could ever be written.
|
||
- **`ludic.render3d`: `terrain_island(cx, cz, r, fall)`** keeps land out to `r` and then
|
||
scales the terrain down into the water over `fall` metres. Scaling rather than blending
|
||
to a fixed bed is what makes the coastline come out of the terrain already there: low
|
||
ground becomes beach and shallows, high ground becomes cliff. `r = 0` leaves the survey
|
||
untouched.
|
||
- **`ludic.render3d`: a rim outline on an actor.** An actor gains `outline` (metres of
|
||
rim) and `ocol` (its colour). The pass draws the model again with its vertices pushed
|
||
along their normals and its front faces culled, so only the far side of the swollen
|
||
shell survives - a silhouette exactly `outline` wide, depth-tested against the scene, so
|
||
anything standing in front of the actor hides its rim too.
|
||
- **`ludic.render3d`: the moon has a phase.** One number, 0 new .. 0.5 full .. 1 new
|
||
again, decides the disc's terminator, how much of its light reaches the ground, and
|
||
where in the sky it rides - a full moon rises as the sun sets, a new moon travels with
|
||
the sun and is never seen. A new moon is now a properly dark night, which is what makes
|
||
a carried light worth having.
|
||
|
||
### Fixes
|
||
|
||
- **`ludic.render3d`: water read the window's size, not the frame it drew into.** The
|
||
water shader's `u_screen` and the reflection target were sized from the drawable, but
|
||
`gl_FragCoord` there runs over the scene target. They match only at a render scale of 1;
|
||
below that the refraction and depth reads landed in the wrong corner of the frame and the
|
||
lake showed a squashed copy of it instead of its own bed.
|
||
## v0.7.0 — 2026-09-10
|
||
|
||
### Features
|
||
|
||
- **A game can use the renderer from outside this repository.** `ludic.render3d` reads two
|
||
things from disk at run time — its GLSL, and the scanned CC0 materials — and both were
|
||
found only by a path relative to the working directory, so the renderer worked in a
|
||
Ludic checkout and nowhere else. A game living in its own repository now needs to copy
|
||
neither.
|
||
|
||
**The shaders come from the package**, wherever the package is. They belong to
|
||
`ludic.render3d` and ship with it, so the renderer looks for them beside the project
|
||
first (a Ludic checkout, where they are under `packages/`) and then under the install
|
||
root, `$LUDIC_HOME/packages/ludic.render3d` — the same place the compiler already
|
||
resolves `import "ludic.render3d/r3d.ludic"` from. Nothing to vendor, and no version of
|
||
the shaders that can drift from the version of the code that compiles them.
|
||
|
||
**`ludic assets [--force]`** fetches the scanned materials and the HDRI sky into
|
||
`assets/polyhaven/` of whatever project you run it in. The list of what to fetch is the
|
||
renderer's own — the renderer decides which materials it wants — so it moved out of the
|
||
repository's `assets/` and into the package as
|
||
`packages/ludic.render3d/assets.manifest`, where it ships with the toolchain. A game
|
||
does not keep its own copy of that list and so cannot fall out of step with the
|
||
renderer's material set. `ludic dev fetch-assets` is the same command from a checkout.
|
||
|
||
**A URL-shaped module built its binary into directories.** `project_name` took
|
||
everything after the last dot of the manifest's module path, which for a package
|
||
identified the way the package manager identifies them —
|
||
`git.host.io/user/name` — is inside the *host*: the build wrote
|
||
`build/io/user/name` instead of `build/name`. The last path segment comes first now, and
|
||
a dot inside that segment still separates namespace from package, so `ludic.render3d`
|
||
still builds as `render3d`.
|
||
|
||
**The camping game has moved out** to its own repository —
|
||
[Maroon Lake](https://git.workshopsoft.io/workshopsoft/maroon-lake) — taking
|
||
`examples/rendering/valley.ludic`, `hiker.ludic`, `camp/`, and 175 MB of survey data and
|
||
scanned kit with it. It was here as a demo of the renderer and became a game, and an
|
||
engine repository should not be carrying a game's assets. It is now the first consumer
|
||
of everything above, which is the point: what the renderer needs a game to be able to do,
|
||
it can now do from outside. `examples/rendering/smooth.ludic` stays as the renderer's
|
||
example in this tree.
|
||
- **OpenGL for Ludic, and a 3D renderer on it.** `Gl.*` binds the whole OpenGL 4.1
|
||
core API — every `gl*` entry point of the platform `gl3.h` as `Gl.<snake_name>(…)`
|
||
with every `GL_*` constant, generated by `ludic-dev glgen` with per-call ABI thunks
|
||
(`runtime/native/gl_thunks.ll`; float/double parameters take `fixed`). Windowed
|
||
builds get an `NSOpenGLContext` on the existing window at Retina resolution
|
||
(`cocoa.ll`); headless builds render into an offscreen CGL context, so a program
|
||
that uses `Gl.*` renders and screenshots identically under the test harness.
|
||
`Gl.open / swap / screenshot / program / vao / floats …` cover the glue, and the
|
||
IEEE-float helpers (`f_add`, `mem_put_f32`, …) let Q16.16 programs fill real
|
||
float vertex and uniform data. `Gl.*` links `gl.ll + gl_thunks.ll + OpenGL.framework`
|
||
only when used; every other build is byte-identical.
|
||
|
||
The `ludic.render3d` package (`packages/ludic.render3d`) is a physically based
|
||
renderer written on `Gl.*`: HDRI sky with image-based lighting (irradiance, GGX
|
||
prefiltered, split-sum BRDF, sun extracted from the map), GPU-generated terrain
|
||
with scanned PBR materials (stochastic anti-tiling, triplanar rock, slope/altitude
|
||
splatting), cascaded shadow maps with PCF and world-unit biasing, a glTF loader
|
||
for scanned models, instanced vegetation with baked impostors, procedural grass
|
||
and lupines with wind and translucency, 4x MSAA with alpha-to-coverage, SSAO,
|
||
still water, cloud shadows, aerial perspective, an HDR pipeline with bloom,
|
||
auto-exposure, ACES tonemapping, grading, sharpening and grain. See
|
||
`examples/rendering/smooth.ludic`, and the Maroon Lake game (git.workshopsoft.io/workshopsoft/maroon-lake) for a
|
||
game built on it. The renderer's CC0 materials are fetched with `ludic assets`.
|
||
|
||
**16-bit PNGs**: the renderer's texture loader keeps 16-bit samples (normal /
|
||
displacement maps) and uploads them as `RGB16` / `R16`.
|
||
|
||
### Fixes
|
||
|
||
- **Resizing the window (or entering fullscreen) no longer empties the world.** It left
|
||
`gl error 1286` — `GL_INVALID_FRAMEBUFFER_OPERATION` — on every frame from there on, with
|
||
the terrain, the trees, the grass and the water gone and only the sky drawn.
|
||
|
||
The sun-visibility pass added in `changes/terrain-perf.md` borrows the depth buffer the
|
||
frame is about to be drawn with, so that rasterising it doubles as a depth prepass. It
|
||
was storing that borrowed texture in its `Target`, and a `Target` deletes whatever its
|
||
`depth` names when it is freed. On the first resize the sequence was: `post_free` deletes
|
||
the frame's depth texture, `post_init` immediately makes the replacement — and GL hands
|
||
back the name that was just freed — and then the visibility target, rebuilt for the new
|
||
size, deleted that name believing it was its own. The scene framebuffer lost its depth
|
||
attachment. What is left is a colour-only framebuffer, which is *complete*, so drawing
|
||
carried on with no depth test at all: the sky is a fullscreen quad drawn last, and with
|
||
nothing left to fail against it painted over the entire valley. The 1286s came from the
|
||
passes whose own attachment now named a texture that no longer existed.
|
||
|
||
A borrowed attachment is never written into the target now, and the frame's depth is
|
||
attached afresh at the start of each pass — it is a different texture every time the
|
||
screen-sized buffers are rebuilt, and one `glFramebufferTexture2D` per pass is cheaper
|
||
than any scheme for noticing that it changed.
|
||
|
||
`R3D_RESIZE_AT=<frame>` rebuilds every screen-sized buffer from that frame on, cycling
|
||
through four drawable sizes every few frames. A window cannot be resized in a headless
|
||
run, so this is the only way to reach the path; it reproduced the fault in one frame and
|
||
now runs twenty resizes, with the fly camera and with the game, without an error.
|
||
- An upgrade keeps the package store. The installer replaces the whole install
|
||
root, and `ludic add` caches packages in `~/.ludic/store` — so re-running the
|
||
one-liner deleted every package a project had fetched. The store is carried
|
||
across now; everything else in the root belongs to the toolchain and is replaced.
|
||
|
||
### Performance
|
||
|
||
- **Ground cover stops re-growing itself.** Walking a streamed world hitched, and the
|
||
hitch got worse the longer you played. Measured in the Maroon Lake game, with a new hitch
|
||
report rather than guessed at.
|
||
|
||
**The chunk cache had a cliff, not a slope.** A stream cached 4096 chunks and then
|
||
stopped remembering: past that the chunk was generated, used for one frame and thrown
|
||
away, so every ring walk regenerated it, for the rest of the session. It arrives after
|
||
enough of the map has been walked — six evictions' worth over seven kilometres, so an
|
||
ordinary session reaches it — and it is the point where cover starts visibly re-growing
|
||
as you turn. `stream_evict` now drops the half of the cache nobody has asked for in the
|
||
longest time (chunks carry the walk that last wanted them) and rebuilds the index over
|
||
what is left. Over a 7 km traversal: generation total **9073 ms → 2230 ms**, the worst
|
||
single frame's generation **11.4 ms → 3.1 ms**, median frame 10.8 → 9.0 ms. With a cache
|
||
deliberately sized to saturate early, the same run goes from 2748 frames generating to
|
||
1548, and from a 13.3 ms median to 9.2. `R3D_NOEVICT` restores the old behaviour for
|
||
comparison, `R3D_STREAM_CAP=<n>` sets the cache size.
|
||
|
||
The other half of that hitch was in the game's own cover generator, and went with it to
|
||
the Maroon Lake game's repository: its candidates were paying for a second noise field, four
|
||
height samples and a path distance before the drift field that rules out most of the
|
||
meadow — 2341 µs → 518 µs per chunk, bit-identical output. Worth repeating in any
|
||
generator: a `stream_fill` is called for tens of thousands of candidates per chunk, so
|
||
the order of its tests is most of its cost.
|
||
|
||
**The hitch report** (`R3D_PROF=1`) is what found both. It prints the slowest frames of
|
||
the run with what was in each: CPU versus GPU wait, cover generated, instance bytes
|
||
uploaded, the game's own tick, and the renderer phase that took longest. Alongside it,
|
||
per-chunk generation cost by stream and band, a census of what the caches hold, and a
|
||
stutter figure — the frame time a run spent beyond 1.2x its own median — because a mean
|
||
cannot show a hitch and a maximum is one unlucky frame.
|
||
|
||
It also found two content bugs in the game it was measured on, which is the report doing
|
||
its job: a cover stream whose placement rule never fires still pays full generation cost,
|
||
and the census makes that visible — 3364 cached chunks holding zero instances.
|
||
- **The ground costs half what it did.** Measured in the Maroon Lake game, the terrain was 10.3 ms of
|
||
a 22.2 ms frame; it is now 5.4 ms of 16.4 ms — 45 fps to 61 fps at 1080p, with the
|
||
frame otherwise unchanged (every viewpoint tested stays above 54 dB PSNR against the
|
||
old renderer, with no channel differing by more than 7/255).
|
||
|
||
**Measure by frame time, not by the pass timers.** `R3D_PROF`'s per-pass
|
||
`GL_TIME_ELAPSED` queries cannot be trusted on this driver: with the ground's shading
|
||
work removed the terrain query fell from 10.5 ms to 1.3 ms while the frame time did
|
||
not move at all. Every number above and below is a median real frame time, taken by
|
||
switching one thing off (`prof_ft_report`); the pass timers are still printed, and are
|
||
still useful for spotting a pass that appears out of nowhere, but they cannot size one.
|
||
`R3D_NOTERRAIN` skips the ground, `R3D_TNEARONLY` / `R3D_TFARONLY` draw every patch
|
||
with one tier's program, and `R3D_RES=<w>x<h>` renders at another size — the three
|
||
switches that say whether a cost is the ground, which tier it is in, and whether it is
|
||
pixels at all.
|
||
|
||
**Each detail tier is its own program.** `terrain.frag` holds a detailed near tier and
|
||
a cheap far one and chose between them per pixel, so every pixel of the valley walls
|
||
was compiled — and scheduled — for a near path it never ran. CDLOD selection now knows
|
||
which tiers a patch can contain: one that never comes within the split draws with
|
||
`FAR_ONLY`, one wholly inside it with `NEAR_ONLY`, and only the ring of patches that
|
||
straddle the band needs the program that holds both and cross-fades. Pixel-identical,
|
||
and it makes the tiers separately measurable: the near tier costs 7.5 ms over a whole
|
||
frame, the far tier 2.3 ms.
|
||
|
||
**The sun visibility is its own pass** (`tersun.frag`). The same CDLOD patches are
|
||
rasterised once into a screen-sized R8 buffer that holds nothing but each ground
|
||
pixel's sun visibility, and `terrain.frag` fetches it by fragment coordinate. The pass
|
||
costs 0.27 ms, shares the frame's depth buffer so it doubles as a depth prepass, and
|
||
takes the cascade read out of the shader that covers the screen. It picks its tier —
|
||
filtered PCF near, a single tap far — over the same cross-faded band the ground uses,
|
||
so the boundary is not a contour you can find on the hillside.
|
||
|
||
**Nothing is sampled for a weight of zero.** The ground sampled all four of its
|
||
materials for every pixel and then blended three of them at zero: a meadow pixel took
|
||
nine taps of triplanar rock, a cliff pixel nine taps of stochastic grass, and every
|
||
pixel in the valley took the snow tile and the four noise fields behind the lake's
|
||
shore wash — a wash that is a hairline along one shore within 120 m of the camera. The
|
||
survey photograph's classification now runs first, because it is what decides which
|
||
materials are present; each material block sits behind its own weight; the ridge field
|
||
that ragged the snow line is skipped 160 m below it, where it cannot change anything;
|
||
and inside the stochastic blend a cell's rotation, offset and rotated gradients are
|
||
computed inside its own test, so a cell whose sharpened weight rounds away costs
|
||
nothing. All of it exact where the weight is zero, and it is most of the win.
|
||
|
||
Material sampling is what remains (2.8 ms of the 5.4): scanned 2K tiles taken at 16x
|
||
anisotropy on ground seen at a grazing angle. `R3D_ANISO=<n>` sets the filter (the
|
||
default is unchanged at 16; 4 is worth 1.0 ms and 1 is worth 1.7 ms).
|
||
|
||
The shadow pass is 1.2 ms of the frame and has nothing to give: re-using the far
|
||
cascades between frames — their windows are snapped to a 14 m and a 64 m grid — is
|
||
worth 0.15 ms standing still and nothing while walking, so it is not in the tree.
|
||
## v0.6.1 — 2026-09-05
|
||
|
||
### Fixes
|
||
|
||
- **`ludic new` scaffolded a project that would not compile.** The project name went
|
||
straight into the `program <Name>` identifier, so `ludic new my-game` wrote
|
||
`program My-Game` — a subtraction — and the first `ludic run` failed with
|
||
`expected '{', got '-'`. A name is now turned into a valid identifier
|
||
(`my-game` → `MyGame`, `2048` → `Game2048`), and a name that cannot be a
|
||
directory or a package is refused with the rule rather than mangled.
|
||
|
||
Found while auditing every command's flags and arguments, along with:
|
||
|
||
- **Unknown options are errors.** `ludic build --headles` silently built a
|
||
windowed binary; `ludic build -o` with no path silently ignored it. Both now
|
||
say what is wrong and exit non-zero.
|
||
- **`ludic fmt` formats in place**, as its help always claimed — it was printing
|
||
the file to stdout and changing nothing. `ludic fmt --check` reports drift
|
||
without writing, for a hook or CI.
|
||
- **`ludic test nosuch.ludic`** says the file does not exist instead of passing it
|
||
to the compiler.
|
||
- **`ludic build-lib`** reported failures as a shell syntax error (an
|
||
interpolation written in a non-interpolating string), and its no-argument
|
||
auto-detection picked up `package.lock.ludic` as a module to compile.
|
||
- Error messages that still began with `x:` — the CLI's old name — now say
|
||
`ludic:`.
|
||
## v0.6.0 — 2026-09-05
|
||
|
||
### Refactoring
|
||
|
||
- **`ludic` is only the language's command line now.** The toolchain's own tasks —
|
||
building the compiler from its IR seed, the regression suites, the docs site,
|
||
releases — moved out of it into a separate `ludic-dev` binary that is built from
|
||
a checkout and is not part of an install.
|
||
|
||
- **`ludic help` is what a user can actually do**: `new`, `run`, `build`, `test`,
|
||
`add`, `fmt`, `lsp`, `doctor`, `upgrade`. No section about a repository they do
|
||
not have. Typing `ludic dev …` says where those tasks went rather than failing
|
||
as an unknown command.
|
||
- **`ludic dev <task>` becomes `ludic-dev <task>`** for contributors; every task
|
||
is otherwise unchanged. The bootstrap is now
|
||
`bin/ludicc tools/ludic-cli/dev.ludic -o bin/ludic-dev && bin/ludic-dev build`.
|
||
- The shipped binary drops from ~880 KB to ~190 KB, since none of the release,
|
||
docs-generation or bootstrap machinery is linked into it any more.
|
||
|
||
### CI
|
||
|
||
- The docs deploy triggers on a change to `install.sh`. The site publishes the
|
||
installer, but the workflow's path filter did not mention it — so a release that
|
||
only fixed `install.sh` left the old script live at the URL the landing page
|
||
tells people to pipe into `sh`.
|
||
## v0.5.2 — 2026-09-05
|
||
|
||
### Fixes
|
||
|
||
- The installer also covers a non-login interactive `bash` — the shell most Linux
|
||
terminal emulators start, which reads only `~/.bashrc`. That file is now created
|
||
when it is missing (its presence changes nothing else about how bash starts),
|
||
while `~/.bash_profile` is still only appended to when it already exists, since
|
||
creating that one would stop bash reading `~/.profile`.
|
||
## v0.5.1 — 2026-09-05
|
||
|
||
### Fixes
|
||
|
||
- `ludic version` reports the version of the toolchain it belongs to. It looked for
|
||
`bin/ludicc` and `VERSION` beside the *current* directory, so it answered
|
||
"(version unknown)" from a project — which is the only place a user ever runs it.
|
||
It now resolves the compiler through the install root, like every other command.
|
||
|
||
`install.sh` also now puts `ludic` on `PATH` for every shell, not just the one
|
||
`$SHELL` names. The PATH edit lives in one file (`~/.ludic/env`) that each
|
||
profile sources, and the profiles are chosen to cover what people actually open:
|
||
`~/.profile` for sh and login bash, `~/.zshenv` because zsh never reads
|
||
`~/.profile`, `~/.bashrc`/`~/.bash_profile` when they already exist, and fish's
|
||
config when fish is installed. Re-running the installer does not add a second
|
||
copy, and `--no-modify-path` still touches nothing.
|
||
## v0.5.0 — 2026-09-05
|
||
|
||
### Features
|
||
|
||
- **One command installs Ludic, and `ludic` is the command you use.** Getting
|
||
started no longer means cloning the repository and learning a task runner called
|
||
`x`.
|
||
|
||
- **`curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh`** installs a complete
|
||
toolchain — compiler, CLI, engine runtime, bundled `ludic.*` packages,
|
||
formatter and language server — into `~/.ludic` and puts it on your `PATH`.
|
||
Prebuilt artifacts are verified against a published checksum; where none
|
||
exists for the platform, the installer bootstraps from the compiler's own IR
|
||
seed with clang. Uninstalling is `rm -rf ~/.ludic`, and `ludic upgrade`
|
||
re-runs the same script.
|
||
- **`ludic` replaces `x`** and is the only command a user of the language meets:
|
||
`ludic new` scaffolds a project that builds and plays as it stands, `ludic
|
||
run` / `ludic build` compile it (`--headless` for a deterministic render),
|
||
`ludic test` runs every `test` block in the project, and `ludic add` / `get` /
|
||
`update` / `verify` / `vendor` drive packages. `ludic fmt` and `ludic lsp` are
|
||
the formatter and language server, so an editor needs no path configuration.
|
||
`ludic doctor` reports whether the install is complete and usable.
|
||
- **The toolchain's own tasks moved under `ludic dev`** — `dev build`, `dev
|
||
test`, `dev reseed`, `dev bootstrap-cfree`, `dev docs-gen`, `dev release` and
|
||
the rest are unchanged apart from the namespace. `bin/x` is gone; the
|
||
bootstrap is now `clang selfhost/ludicc.seed.ll -o bin/ludicc && bin/ludicc
|
||
tools/ludic-cli/main.ludic -o bin/ludic`.
|
||
- **An install root is a first-class layout.** The compiler derives it from its
|
||
own location — the parent of its `bin/` directory — so `~/.ludic` and a repo
|
||
checkout are the same shape, and `$LUDIC_HOME` is no longer needed to build a
|
||
windowed game outside the repo. The bundled `ludic.*` packages resolve from
|
||
`$LUDIC_HOME/packages`, so `import "ludic.core/components.ludic"` works with no
|
||
`ludic_modules/` to set up. Release artifacts are complete install roots
|
||
(`bin/` beside `runtime/`, `packages/` and `VERSION`) rather than bare
|
||
binaries, and the installer ships with the documentation site it is served
|
||
from.
|
||
|
||
### Fixes
|
||
|
||
- **Token cards are back on the docs site.** Clicking a keyword, type, builtin or
|
||
namespace method in any code sample opens its summary card again. The card's
|
||
styling had been left behind in `docs.css` during the site redesign, so on the
|
||
landing page — which links only `base.css` and `site.css` — the card rendered
|
||
unstyled at the foot of the document instead of beside the token. It now lives
|
||
in `base.css` with the rest of the highlighter chrome, and is positioned
|
||
`fixed`, matching the viewport coordinates the script computes, so a card opened
|
||
on a scrolled reference page lands on its token rather than off it.
|
||
- Release checksums are one `.sha256` file per artifact instead of a single
|
||
`SHA256SUMS`. A release is assembled from more than one host — a Linux runner
|
||
cannot build the macOS toolchain — and `ludic dev publish` never overwrites an asset that
|
||
is already attached, so a shared `SHA256SUMS` was written by whichever host
|
||
published first and then never covered anything added afterwards. Per-artifact
|
||
names compose across hosts. Verify one with
|
||
`shasum -a 256 -c ludic-X.Y.Z-src.tar.gz.sha256`.
|
||
|
||
### Documentation
|
||
|
||
- CONTRIBUTING documents what a self-hosted Forgejo runner needs for CI to work at
|
||
all: every workflow clones `${{ github.server_url }}`, which on a self-hosted
|
||
instance is an internal address, so job containers must be able to resolve it.
|
||
The runner's default is a fresh per-job network the Forgejo container is not on,
|
||
which fails the clone — intermittently, because Docker forwards unresolved names
|
||
to the host resolver, so CI can look healthy for a while before it stops.
|
||
- The README is rewritten around what a reader needs first: what the language is,
|
||
a code sample, how to build it, and an honest status. Removed the repo-layout
|
||
table and the Chrono Rift keybindings (a game manual in a language README), the
|
||
nine links to wiki pages that no longer exist, and a "language at a glance"
|
||
bullet describing a retired vocabulary — it advertised `system`, `reads`,
|
||
`writes`, `requires` and `ensures`, none of which are keywords; the declaration
|
||
keyword is `handler`.
|
||
|
||
### CI
|
||
|
||
- The commit-lint workflow survives a force-push. It linted
|
||
`${{ github.event.before }}..${{ github.sha }}` without checking that `before`
|
||
still resolves, so rewriting or garbage-collecting that commit failed the job
|
||
with `fatal: Invalid revision range` on a push whose messages were all valid. It
|
||
now falls back to linting the tip commit when `before` is gone.
|
||
- The docs deploy is serialised. Publishing is a force-push of an orphan `pages`
|
||
branch, so two runs racing could land out of order and leave the site holding the
|
||
older build — with both runs reporting success. A `pages-deploy` concurrency
|
||
group with `cancel-in-progress` means a newer push cancels an older in-flight
|
||
build instead of queueing behind it.
|
||
## v0.4.0 — 2026-09-05
|
||
|
||
### Features
|
||
|
||
- **Prefabs.** `prefab Grunt: Creature { Stats { hp: 30 }, Weapon { def_id: 1 } }` names a model with preset fields; `spawn Grunt { Position { x: 40 } }` spawns it, the spawn's own fields winning over the presets. Prefabs chain (`prefab Grunt: Foe` where `Foe` is a prefab) so shared presets live once. `spawn` is now also an expression yielding the new entity (`let e = spawn Grunt { … }`), and `Prefab.spawn(name: "Grunt")` spawns one chosen at runtime by name (-1 when none matches).
|
||
- **`countdown` fields.** A component field declared `frames_left: countdown = 0` is an `int` the engine steps toward 0 once per Update for every live entity carrying the component (never below 0). Roll timers, invulnerability frames, hit flashes and cooldowns need no hand-written "decrement each frame" handler: set the field, test it.
|
||
- A `machine` over an enum-typed store maps its states to the enum by name: with `enum HeroState { Idle, Rolling }` and `var hero_state: HeroState = HeroState.Idle`, `machine hero_state { state Idle { … } state Rolling { … } }` dispatches on `HeroState.Idle` / `HeroState.Rolling` — no `state Idle = HeroState.Idle` repetition, and a state that names no variant is a compile error. A bare enum is now a first-class `int`-sized type for `var`, params, fields and returns (`llty`), and `Enum.Variant` folds in a global initializer.
|
||
- A handler inside a scene `layer` may carry `@Queries(these: […], on: Model)`, so a scene can own its per-entity systems (`@Queries(these: [Particle]) handler AgeSparks phase Update { … }` runs once per matching entity only while that scene is active). Any other annotation on a layer handler is reported.
|
||
- A program-scope `var` may be initialized with any expression: `var run: Progress = new Progress`, `var speed: int = BASE_SPEED * 2`, `var origin: IVec2 = IVec2.zero()`. Initializers the compiler cannot fold run once at startup (`@L_init_globals`, after the runtime boots and before the `Start` phase), in declaration order. Previously such an initializer was silently replaced by `0` / `null`.
|
||
- Engine managers for what every action game hand-rolls: **`Fx.sparks` / `Fx.number` / `Fx.clear`** — engine-owned sparks and floating damage numbers, moved and aged each Update and drawn after the sprites, with no component, model, handler or draw call in the game; **`Audio.define(name:, path:)` + `Audio.play(name:)` / `Audio.play_music(name:)` / `Audio.named`** — a sound bank by name (the handle form still works); **`Camera.shake_for(amount:, frames:)`** — a timed shake the engine decays; **`Assets.enqueue`** now loads `.wav` / `.mp3` into the sound bank and `.ttf` / `.ttc` into a font table (**`Assets.font(name:)`**) alongside images, so one loading scene covers everything; **`Prop.count()`** — how many live entities carry a component.
|
||
- Every compiler diagnostic is reported as `file:line: error: message` — the file the line really lives in, even through imports — so editors can jump to it. Unexpected characters are errors (they used to be skipped silently), and defining one `function` twice is reported in source terms instead of failing in the IR assembler.
|
||
- Less to write for a game. **`Map` cell API** — `Map.get/set/fill/rect/border/random_cell/is_solid/is_solid_at/width/height`: a game edits the engine's tilemap in place and asks it what is solid (from the `Solids` config) instead of keeping its own grid. **`Sprite` does the small animation work**: `move_id` is the strip drawn while the entity moves, `face: 1` turns it toward its movement, and the `flash` / `blink` countdowns give a white hit flash and an invulnerability blink with no handler. **`scene X shows Menu`** — the engine opens the menu (and frees the cursor) on enter, draws it last in Overlay, and closes it on exit. **`import "dir/*.ludic"`** imports a directory in name order. **`IVec2.distance2/within/heading/along/step`** and **`Angle.diff_degrees`** cover the geometry every action game rewrites; **`List.sample`** draws distinct random picks; **`Input.move_i`** is the standard top-down movement intent; **`AimMode.Auto`** aims with the mouse, or the right stick while a pad is connected; **`Weapon.set_rate` / `Weapon.rate`** change a fire rate in place; projectiles now die on `Solids` tiles by themselves; **`Screen.bar`** draws a meter.
|
||
Handlers inside a scene's layers are scene-qualified (`Play_Draw`), so two scenes may both name a handler `Draw`; `enable` / `disable` of a scene's own handler by its bare name still works from inside that scene.
|
||
- The engine's numeric parameters have names. ludic.core: `BodyPolicy { Platformer, TopDown }`, `BoundsPolicy { Clamp, Wrap, Bounce, Kill }`, `AnimMode { Loop, Once, PingPong }`; ludic.shooter: `AimMode { Mouse, RightStick, MoveDirection, NearestEnemy }`, `WeaponPattern { Single, Cone, Ring, Spiral }`; ludic.npcai: `BrainModel { StateMachine, Utility, BehaviourTree }`, `AiState { Patrol, Chase, Attack, Flee }`; ludic.gameplay: `StatKind { MaxHp, Attack, Defense, Speed }`, `ModifyOp { Flat, Percent }`; ludic.rpg: `StatusKind { Poison, Regen }`; the input runtime: `CursorMode { Normal, Hidden, Locked, Confined }`. `Body { policy: BodyPolicy.TopDown }` reads as what it is; the old integers still work. The input runtime also names the gamepad buttons: `PadButton { A, B, X, Y, LeftShoulder, RightShoulder, Back, Start }` for `Input.bind_pad(button:)`.
|
||
- The second "write less" round, all generic. **ludic.gameplay**: `Stats` carries the build stats every action game bolts on — `damage_pct`, `crit_pct`, `leech_pct` / `leech_hp`, `thorns`, `fire_rate_pct` — and `Combat.damage` applies them itself (a `Crit` event fires; thorns never reflect thorns); `Stats.add(e, stat, amount)` changes a base stat in place and `Stats.scale_hp(e, percent)` scales hp and max_hp; `StatKind` names every code. **ludic.shooter**: `Dash { frames, speed, cooldown_frames }` with `Dash.start(e, dx, dy)` / `Dash.active(e)` — a dodge roll with i-frames the package guards; `Melee { range, half_arc, damage, knockback, frames, cooldown_frames, arc }` with `Melee.swing(e)` (hits every hostile in the arc, knocks back, fires `MeleeHit`) / `Melee.ready` / `Melee.active`; projectiles drawn by the engine in their weapon's colour (`Weapon.set_color`); `TopDown { reticle, reticle_length }` draws the aim line and a mouse cross; the weapon system honours `Stats.fire_rate_pct`. **ludic.dungeon** (new package): `Dungeon.arena / open_arena / random_style / set_exit / entry_point / opposite / at_edge`, with `Side` and `RoomStyle` — arena rooms with mirrored cover, door lanes and exits by side, built into the engine tilemap. **Compiler**: `scene Splash lasts N then Next` (a timed scene), `button … goto: Scene` (a click changes scene, no listener to write). **Runtime**: `Map.random_cell_far`, `Sprite.draw_meter` (hearts / pips), `Assets.enqueue_dir`, `Collider.center`, `Prefs.max`. Also `scene X loads then Y` (the loading scene: pumped, drawn, `AssetsReady` fired), `Prop.despawn_all()`, `Map.to_tile`, and `Brain { hunt_blind }` in ludic.npcai (seek the nearest hostile without line of sight). `Prefab.spawn_at(name:, at:)` spawns and places; `Weapon.reset(id)` restores a definition (no pierce, no homing, its fire rate). Five regression examples cover the additions (`examples/library/prefabs`, `component_access`, `scene_menus`, `managers`, `combat_kit`). `Random.weighted(weights:)` draws an index by weight; `ui` widgets inherit `font` / `size` / `fg` / `align` from their panel; the input runtime names `MouseButton { Left, Right, Middle }`.
|
||
- `@ClearColor(expr)` takes any constant expression, so a named palette colour (`@ClearColor(COLOR_FLOOR)`) works as well as a hex literal.
|
||
- `Ai.seek` + path-aware `brain_seek` — when a Solids tilemap is present the NPC-AI routes a blocked straight line around obstacles with `Grid.a_star`, so foes flow around pillars instead of getting stuck.
|
||
- `Key.*` compile-time key constants (`Key.Space`, `Key.Escape`, `Key.A`, `Key.Up`, ...), folded like `Color.*`; and `Font.*` / `Ui.* / File.*` namespaces so `png_load`/`font_load`/file I/O are namespaced.
|
||
- `Os.pid()` returns the process id, for scratch files that concurrent runs of one tool must not share.
|
||
- `Overlay` render phase — runs after the engine Render systems (sprites, lights) and before present, so a game's HUD / menus are never painted under an actor. Byte-identical when unused.
|
||
- `Prop.of(entity)` and `Prop.has(entity)` — typed access to one entity's component from an entity handle, the same binding a query loop makes. `Hero.of(player).iframes = 20` reads and writes fields directly (no `World.prop_id` / `World.field_id` / `World.get` reflection chain); `Prop.has(e)` is true when `e` is in range, alive, and carries the property, so `-1` is a safe "no entity". A package that declares a real `prop_of` / `prop_has` function keeps it.
|
||
- `Solids.solid2` — an optional second solid glyph (e.g. a closed door) the move system also blocks.
|
||
- `Sprite.strip(sheet, col, row, count, rows)` — register N consecutive animation frames in one call (the base id for `SpriteAnim`).
|
||
- `Sprite` component draws atlas ids (#90) — `Sprite.atlas = 1` routes `esys_sprite` through `atlas_draw_ex` (scale/flip/tint) so atlas cells / multi-cell spans (tall characters) use the engine sprite-render system, not just the 16x16 table.
|
||
- `Ui.close()` — deactivate the retained UI (no menu open); the readable form of `Ui.open(id: -1)`.
|
||
- `[a, b, c]` list literals build a slice in place; the first element fixes the element type, later elements must match, and `[]` is an error (use `new []T`). Tables of records read as `[Row { … }, Row { … }]`.
|
||
- `ludic.prefs` package — `Prefs.*`, a human-readable `key=value` text store for scores / options (the right tool for "remember my best run"; a whole-world `Save.write` is not).
|
||
- `v.x` and `v.y` read the components of an `IVec2` value (a local, a global, a record field, or a call result) — the readable form of `IVec2.x(v)` / `IVec2.y(v)`. The compiler's static typing now also follows function return types, namespace calls, `Prop.of(e)` and record fields, so `@Computed` fields expand in those positions too.
|
||
- engine tilemap-render system (`TileSkin`, shipped from ludic.core) — one entity per glyph paints the whole `Map.*` grid each Render frame *before* sprites, so a game stops hand-looping the map. `esys_tileskin` registered ahead of `esys_sprite`.
|
||
- engine-driven retained UI — a program with a `ui` block has its navigation ticked by the frame loop automatically (from the frame key) and an activation now emits a `UiClicked { id }` event, so scenes react with `@On(UiClicked)` instead of polling `Ui.clicked`. New `Ui.*` namespace (`Ui.open/tick/clicked/set_text/render/build`).
|
||
|
||
### Fixes
|
||
|
||
- A `UI_Name` handle can be read from any code — a plain function, an `@On(UiClicked)` listener, a global initializer — not only from handlers and scene hooks. The widget table it indexes is now built on first use instead of when `@ui_build` is emitted, which came after functions and listeners and crashed the compiler on such a reference.
|
||
- A `var` declared twice — including a game `var` whose name the spliced engine runtime already uses (`ui_font`, `grid`, …) — is now a compile error naming the variable and, when it is the runtime's, saying so (`variable ui_font is also a variable of the engine runtime; choose another name`). Previously the two became one LLVM global and clang reported a redefinition in generated IR. The same check covers `property` names (`property Cell is also a property of the engine runtime; choose another name`); before, the first declaration silently won and field lookups failed with a confusing message.
|
||
- A `{` or `}` inside a string literal within an interpolation hole (`` `{f("{")}` ``) is text, not structure; the hole scanner used to miscount it.
|
||
- A windowed build that reaches the audio runtime indirectly — through the atlas / `Assets.*` preload queue (which feeds `.wav`/`.mp3` into the sound bank) or the engine sprite-render system, without any `Audio.*` call in the game — now links the native audio backend (`audio.ll` + AVFoundation). Previously `ludicc -o` failed at link with undefined `snd_*` symbols for any windowed game declaring a `Sprite` component; the import of `runtime/native/audio.ludic` now flags the backend link itself.
|
||
- Character literals accept the same escapes as strings (`'\''`, `'\\'` and `'\"'` were silently read as 0); an unterminated character literal is now an error.
|
||
- Hand-written runtime preludes (string, Os.*, Fs.*, Crypto.*, …) now live under their own `@lp_` symbol prefix, so a user `function` named `is_ws`, `str_eq`, `path_join` and the like no longer collides with them at link time.
|
||
- Named arguments now work on namespace functions (`@Namespace(Foo)` and `namespace Foo { export function … }`), not only on builtins and bare functions: `Weapon.def(name: "pistol", fire_rate: 9, damage: 14, speed: 8, spread: 0, pellets: 1, pattern: 0)` reorders to the declared parameter order like any other call. Previously every named call on a namespace function failed with "wrong number of arguments".
|
||
- The documented bootstrap works on a fresh clone. `bin/` is gitignored and not
|
||
checked in, so `clang selfhost/ludicc.seed.ll -o bin/ludicc` — the first command
|
||
in the README, in COMPILING, in CONTRIBUTING and on the site — failed with
|
||
`ld: open() failed, errno=2 for 'bin/ludicc'`. Every copy now begins with
|
||
`mkdir -p bin`, which is what CI had been doing all along.
|
||
- Unary minus keeps its operand type: `-f` on a `fixed` is a `fixed` (it was typed `int`, which broke mixed arithmetic and comparisons).
|
||
- `become Scene` now works from an `@On(Event)` listener, a global handler, or a plain function (#91). Code outside a scene's own layers cannot know the leaving scene at compile time, so the compiler emits `@L_scene_leave()` — a dispatch on the live scene id that runs its `on exit` — and calls it there. Previously a listener's `become` reused the last emitted handler's scene (or crashed), and a global handler's `become` skipped the leaving scene's `on exit` entirely.
|
||
- `ludic-fmt` keeps `rows[i]`, `new []int`, `s[a..b]`, `emit(…)`, `~x` and list-literal braces tight, and recognises `<< >> & | ^ ~` as operators.
|
||
- `ludic-fmt` no longer glues an opening parenthesis to a preceding operator: `let moving = (a or b)` stays as written instead of becoming `let moving =(a or b)`.
|
||
- `self()` inside an `@OnSpawn(Model)` or `@OnAttach(Property)` body is now the entity being constructed. Previously it was the entity of the innermost query loop — or the constant 0 when the spawn happened outside any loop — so a hook such as `@OnSpawn(Hero) handler Remember { player = self() }` silently recorded entity 0.
|
||
- `x += y` / `-=` / `*=` / `/=` now lower exactly like `x = x op y`: a Q16.16 `fixed` multiplies and divides through the 64-bit path, a string `+=` concatenates, and an `int` added to a `long` widens (they previously emitted raw integer arithmetic on the LLVM type).
|
||
- `x release` keeps a changeset's markdown intact. Bodies used to go through
|
||
`tr '\n' ' '`, which flattened every multi-line changeset into one paragraph —
|
||
nested bullets came out as inline `" - "` runs and a release read as a single
|
||
unbroken wall of text. A section is now grouped by change type (**Features**,
|
||
**Fixes**, **Performance**, …) with one bullet per changeset and continuation
|
||
lines indented to stay inside it.
|
||
|
||
- `x release --dry-run` renders the next section to stdout and writes nothing,
|
||
so a release can be read before it is cut.
|
||
- `x changelog-section <version>` prints one release's section from
|
||
`CHANGELOG.md`; `x changelog-render` re-renders a section from a directory of
|
||
changesets. The v0.1.0 and v0.3.0 sections were re-rendered with these.
|
||
- cursor `mode 3` (confined) now keeps the OS cursor **associated** (absolute position preserved) and hidden, instead of dissociating it like `mode 2` (lock/relative). Only true-lock `mode 2` uses relative deltas now; `win_mouse` reports the absolute position on `mode 3` and clamps it to the framebuffer. And `mode 3` now **physically confines** the cursor: each frame the platform layer warps it back to the window's content rect (`CGWarpMouseCursorPosition`) whenever it strays past the edge, so clicks can't land outside and the window keeps focus. This lets a top-down game hide + confine the cursor while `aim_mode 0` (mouse aim) keeps resolving to where the reticle points — previously any confine/lock mode silently broke absolute mouse aim, and a confined cursor still escaped the window (#89 follow-up).
|
||
- reserved words (`new`, `match`, `spawn`, ...) can no longer name a function — the compiler errors instead of miscompiling.
|
||
- the shooter aims / homes / fires from a body's **centre** (Position + Collider offset + half-size) instead of the Position anchor, so auto-aim and homing target what is drawn, not a corner.
|
||
|
||
### Documentation
|
||
|
||
- The generated site is redesigned around reading rather than launching: a warm
|
||
paper ground with a serif display face, one ink-blue accent, and rules instead
|
||
of floating cards. Colour is reserved for code. Dark mode is the same design
|
||
with the ground inverted, driven entirely by tokens under one
|
||
`prefers-color-scheme` block, and the landing page's scroll-reveal animations,
|
||
gradient headline, glowing badge and emoji feature icons are gone.
|
||
|
||
- Stylesheets are linked files (`base.css` + `site.css`/`docs.css`) instead of
|
||
being inlined into all 900+ pages, which cuts the published site from 16 MB to
|
||
5 MB and means a design change no longer requires regenerating to be seen.
|
||
- Fonts are the platform's own; the site makes no webfont request.
|
||
- `api.css` was dead — the generator never referenced it — and is removed along
|
||
with `item.css`, which `docs.css` replaces.
|
||
- A page no longer flashes its own title on every plain visit; only a deep link
|
||
highlights its target, and under `prefers-reduced-motion` the highlight no
|
||
longer stays on the element permanently.
|
||
- The copy leads with what is verifiable — ahead-of-time compiled, an ECS in the
|
||
syntax, deterministic fixed-point, no C in a build — and the "get started"
|
||
steps now begin with the clang-plus-seed bootstrap, without which `bin/x` does
|
||
not exist on a clean checkout.
|
||
|
||
### Build
|
||
|
||
- `x` no longer prints a clang warning on every build. Each `clang` invocation the
|
||
task runner makes now passes `-Wno-override-module`, the same flag `ludicc`
|
||
already passes for its own link step: the emitted IR names no target triple, so
|
||
clang substitutes the host's and says so — four times per `x build`, with
|
||
nothing to act on. (The comment in `selfhost/main.ludic` claimed the opposite,
|
||
that the IR *does* carry a triple; it does not.)
|
||
|
||
### CI
|
||
|
||
- Releases are published by CI from a tag instead of by hand from a laptop. The
|
||
new `release` workflow triggers on a `v*` tag, builds the toolchain from the IR
|
||
seed, runs `x test`, `x test-tools` and `x bootstrap-cfree` against the tagged
|
||
tree, and only then creates the Forgejo release. It refuses to publish when the
|
||
tag and `VERSION` disagree or `CHANGELOG.md` has no section for that version.
|
||
|
||
`x publish [vX.Y.Z]` is the command behind it and works locally too: it builds
|
||
`dist/` (a source tarball from the tag, this host's toolchain, and a
|
||
`SHA256SUMS` covering both — releases previously shipped no checksums) and takes
|
||
the release notes from that version's `CHANGELOG.md` section, so the notes and
|
||
the changelog cannot drift. Re-running it only adds assets the release is
|
||
missing, which is how a macOS build gets attached to a Linux-built release.
|
||
## v0.3.0 — 2026-09-02
|
||
|
||
### Features
|
||
|
||
- **Tiled map support (#67–#74)** — load and draw [Tiled](https://www.mapeditor.org/) maps (TMX/TSX/TX and TMJ/TSJ/TJ), the design record from #66.
|
||
|
||
- **P0 parsing primitives (#67)** — a minimal pure-Ludic XML reader (`Xml.*`) for the element/attribute/CDATA subset TMX/TSX/TX use; standard base64 decode/encode (`Base64.*`, RFC 4648), whose decoder ignores the whitespace Tiled wraps into `<data>`; and gzip framing (`z_gunzip`, RFC 1952) wrapping the existing DEFLATE inflater. zlib and the JSON reader already shipped. A curated, attributed golden corpus lands under `assets/tiled-fixtures/`.
|
||
- **P0.5 TMX/TSX reader (#68)** — `Tiled.read` / `Tiled.read_tsx` map the native XML formats onto the *same* intermediate the JSON path produces — a `Value` tree in Tiled's JSON schema, with every tile layer's data decoded to a dense GID list (CSV, base64, base64+zlib, base64+gzip). A CSV `.tmx` and a base64+zlib `.tmj` of the same map read structurally identically; the in-repo Kenney `sampleMap.tmx` + external `sampleSheet.tsx` load with no manual JSON re-export.
|
||
- **P1 core load + render (#69)** — the runtime `rt_tmap` model (heap-allocated to `w·h`, lifting the old `96×64` cap), the GID resolver (`Tiled.resolve` → tileset / local id / H·V·D flips), image-backed rendering (`Tiled.draw`, flips applied at blit), and the legacy-tilemap compatibility projection so `Grid.*`/`Path.*`/`esys_move` keep working. `Tiled.load` reads either format, resolves external tilesets + images, and auto-projects a `collision` layer. The Kenney sample loads and renders pixel-identically from `.tmx` and `.tmj`; the `grid` and `physics_tiles` demos now run off a loaded map.
|
||
- **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `<objectgroup>` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline.
|
||
- **P3 animated tiles + tile objects (#71)** — a tileset `<animation>` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites.
|
||
- **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI).
|
||
- **P5 breadth (#73)** — image layers (parallax + repeat) and group layers (flattened, with recursive offset/opacity/tint/visible; `Tiled.layer_kind`/`Tiled.layer_offsetx`/`Tiled.layer_tint`); the isometric / staggered / hexagonal orientation coordinate transforms (`Tiled.cell_x`/`Tiled.cell_y`, driving the tile draw so cells land at the correct screen coords); and Wang-set GID resolution through the standard resolver (the terrain-corner authoring concept is editor-side and ignored).
|
||
- **P6 scale (#74)** — infinite/chunked maps: `<chunk>` (TMX) and JSON `chunks[]` decode and flatten into the dense layer; `.world` stitching (`Tiled.world`/`Tiled.world_count`/`Tiled.world_map`) lists member maps at their offsets; and a self-contained pure-Ludic **zstd** decompressor (`z_zstd`, RFC 8878) for base64+zstd layers — frame + raw/RLE/compressed blocks, raw/RLE/direct-weight-Huffman literals, and the full FSE sequence path — decoding the low-entropy GID streams a tilemap produces (a high-entropy FSE-compressed-Huffman-weights block fails cleanly with -1 rather than emitting wrong bytes).
|
||
- Builtin NPC AI (#61) — the source package **ludic.npcai**, a perception → decision → action stack that plugs into the other controllers instead of re-implementing movement. The AI never moves a body directly: it writes the SAME intent fields the player controllers read (`want_x`/`want_y`/`want_fire`, `want_jump`), so an enemy gunner reuses the shooter's weapon/projectile/auto-aim systems verbatim (set the body's `TopDown.aim_mode = 3`) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. **Perception** (`Vision` + `Memory`, throttled `esys_perception` with faction filtering and optional `Grid` line-of-sight) remembers the nearest hostile and emits `TargetSpotted`/`TargetLost`. **Decision** offers three models writing one `Brain` intent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able via `cancellable DecisionMade`. **Steering** adds Reynolds flocking (separate/cohere), and a `Follower` component gives companion stances. Reuses ludic.gameplay Faction (who is hostile) + Stats (hp for flee). Fully deterministic: perception + replan are frame-throttled and fixed-order. Example: `examples/games/npcai_demo.ludic` — one enemy perceives, chases and shoots a target through the shooter controller, flees at low hp under the utility model, and a companion follows its leader.
|
||
- Builtin Platformer controller (#58) — the reference implementation of the six-lever extensibility contract, shipped as the source package **ludic.platformer**. Movement feel is all defaulted POD data (`Platformer { move_speed, jump_height, apex_frames, fall_gravity_mul, coyote_frames, jump_buffer_frames, air_jumps, policy, … }`); the controller is decomposed into small engine-owned sub-systems — input (Input phase), move/gravity/jump (FixedUpdate, before the shared `esys_move` sweep) and animation-state (LateUpdate, after it) — each independently switch-off-able with `disable system <fn>`. It owns movement *policy* only and reuses the engine Body/Collider swept-AABB collision. Jump feel derives gravity + impulse from height/apex, with coyote time, jump buffering, variable jump height and multi-jump; every jump decision emits a `cancellable JumpRequested` / `JumpPerformed` / `Landed` / `StateChanged` event, and a gravity `policy` enum (asymmetric / symmetric / floaty) is the formula hook. Ships the opt-in game-loop layer too (`scaffolding.ludic`): moving & crumbling platform blocks with rider carry, collectibles + `Score`, springs, hazards + a light `Life`/i-frames model, and checkpoint/goal triggers. Deterministic integer Q16.16 throughout. Examples: `examples/games/platformer_demo.ludic`, `examples/games/platformer_scaffolding.ludic`. Also fixes a latent codegen bug in `ludic_sweep_entity` (an SSA register name collided once a program declared ≥11 events).
|
||
- Builtin RPG systems suite (#59) — the source package **ludic.rpg**, seven independently-usable modules on the six-lever contract, with name-keyed data registries so a game or mod adds content with zero code. **A Movement** — one `Mover` with a `mode` selector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, and `cancellable MoveRequested` (locked doors/ice) / `TileEntered` (encounters) / `Interacted` (the action-button raycast). **B Inventory** — a name-keyed item registry, per-owner counts, gold, `ItemUse` veto, and `Equipment` whose bonuses flow through the gameplay Stats modifier stack. **C Crafting** — a data-driven recipe + ingredient registry; `Craft.can`/`Craft.make` consume from the inventory. **D Quests** — quests + objectives whose progress is driven by `Quest.notify` (route any gameplay signal in), auto-completing when met, plus a global flag store for branching. **E Dialog** — an Ink/Yarn-style graph registry (nodes + choices) with a per-speaker `Dialog` component and `cancellable DialogChoice` for skill-check gating. **F Puzzles** — Sokoban `Pushable` + a switch / pressure-plate / gate signal graph (logic puzzles with no code). **G Status** — over-time poison/regen effects routed through the shared Combat pipeline. Everything is integer-deterministic, so save/load (world_save) and rollback hold. Example: `examples/games/rpg_demo.ludic` (21 self-checks across all seven modules).
|
||
- Builtin top-down Shooter controller (#60) — the source package **ludic.shooter**, conforming to the six-lever contract and built on the engine Body/Collider + ludic.gameplay Faction/Combat/Stats. `TopDown` decouples movement from aim (`aim_mode`: mouse / right-stick / move-direction / nearest-enemy auto-aim, with a `turn_rate` for tank-style rotation). Weapons are a name-keyed **registry** (`Weapon.def("shotgun", …)` — add a gun with zero code) with per-weapon fire-rate, damage, speed, spread, pellet count, pattern (single / spread cone / ring / spiral), plus data-driven pierce (`Weapon.set_pierce`) and homing (`Weapon.set_homing`); `esys_weapon` reads a `want_fire` intent so the **same** weapon fires for a player (input) and an NPC (AI). `Projectile` + `esys_projectile` is a self-contained deterministic pool: integrate, TTL, faction-filtered hit through `Combat.damage`, pierce, and homing that curves onto the nearest enemy — every step observable via `ProjectileSpawned` (mutable/veto) / `ProjectileHit` / `ProjectileExpired`. A budgeted `Spawner` wave director emits `SpawnRequested` / `WaveCleared`. Example: `examples/games/shooter_demo.ludic` (11 self-checks: movement, aim, faction damage, friendly-fire immunity, spread, kill, ring, homing, waves).
|
||
- Canonical engine-ABI components (#77) — the shared `Position` / `Body` / `Collider` / `Solids` bundles the engine-owned movement system (`esys_move`, #65) reads by name are now shipped from a base source package, **ludic.core**, instead of being re-declared by hand in every game and example. A game `import "ludic.core/components.ludic"` and the engine moves and collides its entities for free; extend by *composition* (attach your own components on the same model). AOT means the properties compile straight into the consumer's compile-time ECS with no ABI seam, and everything stays integer + Q16.16 deterministic (lockstep / replay / `world_save` hold). Example: `examples/library/core_components.ludic`.
|
||
- Cursor capture (#89) — `Input.cursor_mode(mode)` hides / locks / confines the OS mouse for a windowed game: `0` normal (visible, free), `1` hidden (hide the OS cursor while focused so a game draws its own reticle), `2` locked (hidden + dissociated — the mouse feeds *relative* motion through `Input.mouse_dx/dy`, and `Input.mouse_x/y` becomes a clamped virtual cursor, the FPS / twin-stick aim mode), `3` confined (dissociated but visible; the mouse cannot leave the window). The platform auto-releases (shows + reconnects the cursor) while the window is not key (Cmd-Tab) and on close, so the cursor is never left captured. Adds the native macOS implementation in `cocoa.ll` (`[NSCursor hide]/[unhide]`, ref-counted and toggled only on change; `CGAssociateMouseAndMouseCursorPosition`; `CGGetLastMouseDelta` for the relative virtual cursor) behind a new `win_cursor_mode` intrinsic; headless / non-windowed it is a no-op (DCE'd). Example: `examples/library/cursor_capture.ludic`.
|
||
- Declarative Render clear + present (#86) — `@ClearColor(0xRRGGBB)` makes the engine own the per-frame clear and flip: at the top of the Render phase it clears the framebuffer to the declared colour, and after the Render handlers run it presents the frame, so a game's Render handler no longer repeats `Screen.clear(color)` / `Screen.show()` and the clear colour is configured *declaratively* rather than in the handler body. Opt-in and backward-compatible: a program with no `@ClearColor` is byte-for-byte identical (it clears/presents itself, or the light system owns the present). Example: `examples/library/clear_color.ludic`.
|
||
- Deterministic camera zoom (#78) — `Camera.zoom(scale)` scales the whole view about the screen centre by a Q16.16 factor (`1.0` = none, `2.0` = 2x in, `0.5` = out). It rides on the same two framebuffer chokepoints (`rt_put_px` / `rt_fill_rect`) that already carry the camera offset, so it composes with `Camera.set`/`follow`/`shake`, and it is a *render-time* transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay and `world_save` are untouched, and the zoom itself is deterministic. Gated by an internal `rt_cam_zoomed` flag so a game that never zooms renders byte-for-byte identically (golden renders unchanged); `Camera.zoom(1.0)` turns it back off. This is the concrete outcome of the #78 position-types investigation (`docs/RFC-POSITION-TYPES.md`), which rejected hardware floats for the deterministic coordinate core and identified zoom as the one genuinely-missing render feature. Example: `examples/library/camera_zoom.ludic` (pixel-readback verified).
|
||
- Directional int input (#79) — `Input.axis_i(neg, pos) -> int` returns a -1/0/1 movement intent (`+1` positive key held, `-1` negative, `0` neither or both) read from the multi-key device set, so turning WASD into movement no longer needs the `ki(key_down('d')) - ki(key_down('a'))` bool-to-int glue and feeds an int mover directly: `dx = Input.axis_i('a', 'd')`, `dy = Input.axis_i('w', 's')`. Complements `Input.axis` (fixed) / `Input.vector` (normalized). Example: `examples/library/input_movement.ludic`.
|
||
- Engine sprite-render system (#85) — the engine already auto-*ticks* SpriteAnim and Motion; it now auto-*draws* too. Declare a `Sprite` component (id + optional offx/offy/scale/flip/tint/hidden, shipped from **ludic.core**) on an entity with a `Position` and the engine draws it each Render frame — no hand-written Render handler querying positions and calling `draw_sprite` per entity, and no hand animation (when the entity also carries `SpriteAnim`, the current frame is added to the base id). Registered on the compile-time engine-system registry for the Render phase and spliced only when a game declares `Sprite`, so a game that never declares it compiles byte-identically; a game wanting a custom draw omits `Sprite` (or `disable system esys_sprite`). Also **deprecates the bare `draw_sprite` / `draw_sprite_scaled` globals** in favour of the namespaced `Screen.sprite` / `Screen.sprite_scaled`: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, since `Screen.sprite` uses it), and the in-repo `chronorift` demo is migrated to the namespaced calls. Example: `examples/library/sprite_render.ludic`.
|
||
- Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`.
|
||
- Gameplay-controller foundation (#57) — the shared, cross-genre building blocks the builtin controllers stand on, shipped as the source package **ludic.gameplay**: a deterministic `Cooldown` frame timer (engine-ticked), a `Stats` attribute bundle with an unbounded timed **modifier stack** (`Stats.total` computes base+flat then percent on demand; expired modifiers self-despawn), a `Faction` friend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and a `Combat` damage pipeline whose `cancellable DamageAboutToApply` hook lets a game veto a hit *or rewrite the amount* (`Combat.set_amount`) and which emits `Damaged`/`Died`/`Healed`. Everything is integer-only so lockstep, replay and `world_save` snapshots hold. Also adds extensibility **lever 5** to the language: `disable system <esys_fn>` drops exactly one engine-owned system's tick at compile time, so a game can carry a well-known component but tick it with its own handler (byte-identical when nothing is disabled; the C-free bootstrap fixpoint is untouched). Example: `examples/library/gameplay_foundation.ludic`.
|
||
- Incremental asset preloading (#82). Assets used to load synchronously inside Boot/Start (`png_load`, `Audio.load`), stalling the first frame(s) as content grows, with no built-in loading phase. Adds an `Assets.*` preload queue: `Assets.enqueue(name, path)` queues a named image without loading it, `Assets.pump(max)` loads up to `max` queued assets per frame (returning how many it loaded), and `Assets.total` / `loaded` / `ready` / `progress` (0..100) drive a progress bar. A loading scene pumps a few assets per frame, draws `Assets.progress()`, and `become`s the play scene once `Assets.ready()`, so the game shows a responsive loading screen and only enters play once content is ready — the deterministic, no-threads form of async preloading (the work is spread across frames instead of stalling one, and the same enqueue+pump order loads identically every run). Loaded assets are reachable by name via `Assets.get` / `Sprite.named`. Builds on the #81 atlas. Example: `examples/library/preload.ludic`.
|
||
- Input Manager + automatic device-layer drive (#83). The generated frame loop now commits the input device layer itself — when a game uses any Input action-map / device method it calls `input_poll` each frame (reading the live key, recording/replaying, and rebuilding the held-key/mouse/gamepad state), so `Input.active` / `Input.key_down` / the mouse and pads read live **without the game calling `Input.poll` by hand** (previously the loop fed only `Input.key`, and the device layer read empty unless the game polled at the top of its Input phase). A game that uses no Input runtime keeps the plain `rt_poll` path, byte-identical. Adds the Input-Manager API on top: `Input.action(name, key)` ships a **default** binding (kept if already bound, so a player's `Input.rebind` or a loaded key-map is not clobbered); `Input.bind_pad(name, button)` makes an action **device-agnostic** (fires from keyboard *or* gamepad); and `Input.active` / `Input.just_pressed` / `Input.just_released` read the whole multi-key device layer with clean on-press / on-release edges (the deterministic, dispatch-free equivalent of event handlers — a handler polls the edge and reacts, so a replay fires identically). Examples: `examples/library/input_manager.ludic`, `examples/library/input_auto.ludic`.
|
||
- Namespace block form (#76) — `namespace Name { export function foo(…) … internal function bar(…) … }` declares a `Name.*` namespace once and controls its public surface declaratively, instead of annotating every function with `@Namespace(Name)` one at a time. Inside the block each `function short(…)` is emitted as `namelower_short`; an `export` function (the default) is callable as `Name.short(…)`, while an `internal` function is a private helper — emitted and callable by its short name from siblings in the block (calls are rewritten to the emitted name), but not part of the `Name.*` surface (`Name.internalOne()` is a compile error). It is the block sugar for the per-function `@Namespace` annotation, so a package's public API reads at a glance. A namespace declared the old per-function way is unchanged. Example: `examples/library/namespace_block.ludic`.
|
||
- Namespaced spritesheet / atlas API (#81). Sprite loading was a bare `png_load("floor0.png")` — one file per 16x16 sprite, with no way to load one sheet and address a cell by grid coords or name. Adds a `Sprite.*` / `Assets.*` runtime (over the variable-size image loader, so a cell is a sub-rect of the kept image and is **not** restricted to the 16x16 sprite table): `Sprite.sheet(path, cellw, cellh)` -> handle, `Sprite.cell(sheet, col, row)` and `Sprite.cell_span(sheet, col, row, cols, rows)` -> id (a sprite may span more than one cell — a tall character, a wide object), `Sprite.define(name, …)` / `Sprite.named(name)` to name and look up a cell, `Sprite.draw` / `Sprite.draw_scaled` (through the camera / zoom / clip, like `Screen.sprite`), `Sprite.width` / `height`, and `Assets.image(path)` / `Assets.load` / `Assets.get(name)`. Spliced on demand; a program that uses neither compiles byte-identically. Example: `examples/library/atlas.ludic`.
|
||
- Optional world boundaries (#84) — declare a single `Bounds` config entity (a rect `x, y, w, h` plus a policy, shipped from **ludic.core**) and the engine-owned world-bounds system keeps every moving `Body` inside the play area each frame, so a game no longer hand-clamps `Position`. Four policies: `0` clamp (walls), `1` wrap (toroidal), `2` bounce (clamp + flip the Body velocity on the axis that hit), `3` kill (despawn a body fully outside). It reads each body's `Collider` size so the whole box stays inside; off by default (no `Bounds` entity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declares `Bounds`, so a game that never does compiles byte-identically. Also adds `World.despawn(entity)` — the reflective, by-id form of the `despawn` statement (runs `@OnDespawn` + frees the slot), which the kill policy uses and any system can call. Example: `examples/library/world_bounds.ludic`.
|
||
- Package manager (#63) — `x add` / `x get` / `x update` / `x verify` / `x vendor` bring third-party packages to Ludic with no new infrastructure. Dependencies are named by their git import path (URL-as-identity, no registry — a `git tag vX.Y.Z` is publishing), resolved by Go-style Minimum Version Selection, fetched into a content-addressed global store (`~/.ludic/store`, keyed by a file-content hash) and linked into each project under `ludic_modules/`. A `package.ludic` manifest declares dependencies, the provided `Foo.*` namespace(s), the kind (source or prebuilt) and, for prebuilt libs, the shipped targets; `package.lock.ludic` pins the resolved versions and content hashes for reproducible, verifiable builds. A source package's Ludic compiles into the consumer via a new module-root import fallback in the compiler (`import "git.workshopsoft.io/user/pkg/foo.ludic"` resolves against `$LUDIC_MODULES`, default `ludic_modules/`), so a package registers a namespace the same way the built-in stdlib does. Namespace collisions and missing prebuilt targets are hard errors. Existing programs compile byte-for-byte identically; the C-free bootstrap fixpoint is untouched. See docs/PACKAGES.md.
|
||
- Package-declarable namespaces & engine systems (#62) — the two hooks that made `Foo.*` stdlib namespaces and engine-owned systems compiler-hardcoded are now data-driven registries, so a package registers them with no compiler edit. `@Namespace(Name)` on a function opens a `Name.method(…)` namespace that dispatches to the bare `name_method` through the same generic path the built-in namespaces use (applied only after them, so it never shadows a core one). `@EngineSystem(Component, Phase)` registers an engine-owned system the frame loop runs each phase when the component is present — the package-declarable form of the built-in SpriteAnim/Motion/Light2D systems, reading components by name through the reflection ABI so an unused registration is byte-identical. Both annotations are keyword-free. The core stdlib keeps its optimized codegen (byte-identical output; the C-free bootstrap fixpoint is untouched) and packages ride the generic registry alongside it. This completes the packaging prerequisite for shipping gameplay-controller libraries (#58–#61) as real packages. See docs/PACKAGES.md.
|
||
- Prebuilt binary packages (#64) — a package can now ship a compiled artifact whose exported functions, systems and components a consumer uses without the source, over the stable reflection C-ABI. `x build-lib <module.ludic>` compiles a package's module to a per-target native dylib (`lib/<target>/`); a `kind prebuilt` dependency is fetched and linked like any other, and `x link-flags` prints the clang flags to link the module dylibs into a game (or `x app` does it in-repo). A module registers its dynamic components (`world_register_prop`) and its `@System(Phase)` functions with the host at load through a constructor, and the host dispatches every registered system each frame — the systems analogue of dynamic components. Binary packages are native-only and second-class ECS by design (source packages remain the portable, first-class, deterministic path); missing a build target is a hard error. See docs/PACKAGES.md.
|
||
- The engine runtime ships with the toolchain, not the project (#75). The compiler auto-splices `runtime/native/*` for any ECS game; a `runtime/...` import that is not found relative to the build is now resolved from the install root **`$LUDIC_HOME`** (default: the compiler binary's directory — where the platform `.ll` files already come from) *before* the package module root. So an external game that consumes the `ludic.*` packages no longer has to copy or symlink the engine runtime into its `ludic_modules/`; that directory holds only third-party packages, and the runtime is part of the toolchain install. In-repo builds are byte-identical (the runtime still resolves locally there, so the `$LUDIC_HOME` fallback never fires and the C-free bootstrap fixpoint is untouched).
|
||
|
||
### Fixes
|
||
|
||
- **Correct the element type of a slice indexed by a member-access expression.** `emit_index_addr` set the global `g_addr_ty` to the slice's element type *before* evaluating the index expression, so an index that was itself a struct-field access (`slice[obj.field]`) overwrote it — the load then came back typed as the field, and a following field access failed with "member access on non-aggregate". The slice branch now sets `g_addr_ty` last, matching the raw-pointer branches. Also de-duplicate the `@strcmp` declaration (centralised in the head prelude) so a program that pulls in both the world table and the filesystem prelude links.
|
||
- Input.key_pressed / key_released edges now fire (#87). In a frame-loop game the edges never triggered: the loop (since #83) commits the device layer once per frame via `input_drive`, but a game that *also* called `Input.poll` by hand committed a second time in the same frame, and `input_device_commit` copies `in_held` into `in_prev` at the top of every commit — so the second commit left `in_prev == in_held` and `key_pressed` (`held && !prev`) / `key_released` could never see a transition. Fixed with an `in_have_frame_driver` flag: the loop's `input_drive` sets it, and a manual `Input.poll` under the loop becomes a no-op that returns the frame's key instead of re-committing. An entry-driven harness has no loop, so the flag stays false and each `Input.poll` still commits a frame of input as before (record/replay and the #50 device tests are unchanged). Example: `examples/library/input_edge.ludic` (press edge on the down frame, release edge on the up frame).
|
||
- Windowed games no longer force-quit on Esc or 'q' (#88). The macOS platform layer (`runtime/native/cocoa.ll` `win_poll`) used to hard-code Escape (keycode 53) and the character 'q' as *quit* — storing `W_running = 0` so a shipped windowed game died the instant a player pressed Esc (a universal pause key) or typed 'q'. Those dev-loop conveniences are removed for windowed builds: **Escape is delivered to the game as key 27** and **'q' is an ordinary key**, consistently across both the single per-frame key (`@W_key`) and the `#50` held-key set (`ev_keyval` now maps Escape→27, not 'q'). A windowed game now owns Esc/pause and shuts down via `quit()` or the window close button (which still ends the run). The headless test driver (`rt_poll` in `core.ludic`) keeps its own `'q'` = quit for scripted golden runs, so nothing headless changes. Also stops forwarding consumed key events to `-sendEvent:`, which was triggering AppKit's system "funk" beep on every keystroke.
|
||
|
||
## v0.2.0 — 2026-09-01
|
||
|
||
The types-and-systems release. Ludic grows a real type system — sum types,
|
||
`option`/`result`, exact and arbitrarily-big numbers, string-keyed containers and
|
||
packed 2D value types — alongside an engine that auto-runs animation, motion and
|
||
lighting over components a game merely declares, a full input stack from
|
||
rebindable action maps to native gamepads, out-of-band Audio / HTTP / Jobs
|
||
standard libraries, and a built-in test framework with line coverage. Every
|
||
addition is gated and additive: a program that never touches a feature compiles
|
||
byte-for-byte identically, and the C-free bootstrap fixpoint is untouched.
|
||
|
||
### Language & types
|
||
|
||
- **feat**: Tagged-union enums (#56) — an `enum` variant may now carry a payload (`enum Tile { Empty, Wall, Door(int), Portal(int, int) }`), making it a sum type. Variants construct by name and `match` destructures them, binding each payload, with exhaustiveness and arity checked so adding a variant surfaces every site to update. Plain enums keep their zero-cost ordinal representation, byte-for-byte unchanged.
|
||
- **feat**: `result` + `try`/`else` (#46) — a fallible function returns `ok(payload)` or `err(message)`, and `try EXPR else { … }` recovers a value with the failure message bound to `error`. A plain branch on the tag: no exceptions, no stack unwinding. `is_ok`/`is_err` classify without unwrapping.
|
||
- **feat**: `option` (#53) — `some(v)` / `none()`, a maybe-a-value with no magic `-1` sentinel, read with `is_some`/`is_none`/`unwrap_or`.
|
||
- **feat**: `panic(msg)` + `assert(cond, msg)` (#8) — a clear, located `file:line: panic:` / `assertion failed:` message and a clean exit 1 instead of a raw segfault; the source location is baked in at compile time.
|
||
- **feat**: `IVec2` + `Rect` 2D value types (#1) — by-value spatial types that lower to packed integers, so they copy like scalars and never allocate: an integer 2D vector for grid coordinates and a Q16.16 axis-aligned rectangle for HUD boxes and hitboxes, both exact and platform-identical.
|
||
- **feat**: `BigInt` + `Decimal` exact numbers (#52) — arbitrary-precision integers and exact base-10 fixed-point for game economies, so an idle counter never overflows and `0.10 + 0.20` is exactly `0.30`. No `f32`/`f64`; deterministic.
|
||
- **feat**: `Huge` + `Angle` + `Percent` (#55) — a display-scale idle/incremental big number (`1.23e45`), an auto-wrapping radian angle over deterministic `Math.*` trig, and a `[0,1]`-clamped fraction for health, volume and interpolation `t`.
|
||
- **feat**: `Dict` + `Set` containers (#54) — string-keyed lookups over one open-addressing hash table (FNV-1a, linear probing, tombstones), for resource counts, registries, tags and visited tiles; O(1) average instead of a linear scan.
|
||
- **feat**: Value tree + reflection + JSON (#44) — a self-describing `Value` node, `Reflect.serialize`/`apply` to walk an entity's whole component set to and from it bit-exactly, and `Json.encode`/`parse` for compact, stable, diffable text. One-call save/load for entities and the backbone of data-driven tooling.
|
||
|
||
### Engine, animation & lighting
|
||
|
||
- **feat**: Engine-owned systems (#43) — the ECS hook that auto-runs a system each frame over a component a game merely declares, no `handler` wired: `SpriteAnim` advances sprite-sheet frames and `Motion` advances value tweens for free. Built on the reflection ABI, so it costs nothing in a game that declares neither.
|
||
- **feat**: Animation ergonomics (#48) — an ergonomic layer over those systems: `Anim.clip`/`Anim.play` for named spritesheet clips, `Anim.on_frame`/`fired` frame events, `Motion.to` one-call tweens, and fluent engine-advanced `Tween` handles (`to`/`chain`/`delay`/`parallel`/`stop`). All integer and deterministic under replay.
|
||
- **feat**: `Light.*` 2D lighting (#4) — a deterministic software light-accumulation pass over the framebuffer: `ambient` tinting, additive radial `point` lights with linear falloff, and hard shadows cast against rectangular occluders. Integer + Q16.16, identical every run and headless.
|
||
- **feat**: ECS-native lighting (#47) — a torch is now just an entity carrying `Light2D`, a wall an `Occluder`, and one `Ambient` sets the night tint; the engine runs the whole light pass at the end of the Render phase with no `Light.*` calls wired by hand.
|
||
- **feat**: Lighting render-quality tiers (#49) — cone/flashlight `spot` lights, a `falloff` exponent, `soft` penumbra shadows, colour `gel` cookies, normal-mapped surfaces (N·L) and a `time_of_day` day/night ramp, all on the same deterministic accumulation core and consumable via optional `Light2D` fields.
|
||
|
||
### Input
|
||
|
||
- **feat**: Action maps + record/replay (#7) — gameplay reads named, rebindable actions instead of physical keys, and the single per-frame `Input.poll` makes deterministic replay fall out for free: `Input.record` captures the tape and `Input.replay` feeds it back exactly — the seed of lockstep netcode.
|
||
- **feat**: Device layer (#50) — multiple simultaneous held keys, analog axes and a normalized vector, the mouse (position/delta/buttons/wheel), gamepads and touch, all injectable on every target (`Input.press`/`set_mouse`/`set_pad`/`set_touch`) and snapshotted whole into the replay tape.
|
||
- **feat**: Native hardware bindings (#51) — the device layer's macOS side wired into cocoa.ll: live cursor position, per-frame GameController polling into gamepad buttons/axes (SDL button order), and NSTouch routing, all DCE'd out of a headless build. No API changes.
|
||
|
||
### Standard library
|
||
|
||
- **feat**: `Audio.*` (#22) — sound effects and music over a new AVAudioPlayer backend: `load`/`play`/`play_music`/`stop`/`volume`/`pitch`/`is_playing`. Out-of-band (real-time, not part of the simulation) but frame-driven, so a replay fires the same sounds at the same frames; headless builds carry it as dead-stripped no-ops.
|
||
- **feat**: `Http.*` (#6) — a poll-based HTTP/HTTPS client for out-of-band data (leaderboards, cloud saves, remote config) that never blocks the frame, over an NSURLConnection transport with system TLS on by default. Pairs with `Json.parse`; the response parser is pure Ludic and tested offline.
|
||
- **feat**: Jobs, Promises & opt-in Sync (#14) — a layered concurrency library. `Job.*`/`Promise.*` are the safe default: cooperative futures pumped a little each frame so heavy work spreads out, combined with `Promise.all`/`race`. The advanced `Sync.*` tier adds mutexes, atomics and bounded channels. A deterministic cooperative scheduler — results are collected on the main thread and a Job never touches the world directly, so lockstep and replays stay bit-exact.
|
||
|
||
### Testing & tooling
|
||
|
||
- **feat**: Built-in test framework (#12) — a `test "name" { … }` block auto-discovered and run by a synthetic runner (no `entry` to write), with `expect`/`expect_eq`/`expect_near` assertions that report every failure and exit non-zero. `expect_near` carries the tolerance fixed-point game math needs.
|
||
- **feat**: Line coverage (#45) — compile with `--coverage` and the compiler instruments each statement with a per-line hit counter dumped at exit; `bin/x test --coverage` aggregates the dumps into a per-file report naming the unreached lines. Flag-gated and additive — an ordinary build stays byte-identical.
|
||
|
||
### Fixes
|
||
|
||
- **fix**: `const` of a non-int type is no longer miscompiled. A `const` reference lowered to its initializer's raw integer bits typed as `int`, so `const X: fixed = 10.0` computed as the raw Q16.16 value `655360` instead of `10.0` — silently corrupting fixed-point math (and, in one case, spinning an infinite loop). Const references now emit their initializer with its real type. Every existing const is an `int` literal, so the lowering there is byte-identical and the bootstrap fixpoint and golden renders are unchanged.
|
||
|
||
## v0.1.0 — 2026-08-30
|
||
|
||
### Features
|
||
|
||
- Editor tooling — `ludic-fmt` (formatter) and `ludic-lsp` (language server), plus VS Code and JetBrains integrations, all built by the toolchain.
|
||
- Namespaced standard library — Math, Text, List, Random, Time, Screen, Color, Ease, Collide, Memory, Vector, DateTime/Date/Duration/Clock, Unicode, Os, Fs/Path/Mime, Log, Noise, Hash, Crypto and Uuid, each deterministic where a game needs it.
|
||
- Native 2D backend — an ECS core with a deterministic fixed-point (Q16.16) runtime, a windowed Cocoa target on macOS and a headless PPM renderer that runs anywhere.
|
||
- Self-hosted, C-free toolchain — the compiler, runtime, task runner and editor tools are all written in Ludic and built from a checked-in LLVM-IR seed with clang alone; `x bootstrap-cfree` proves the compiler rebuilds itself byte-for-byte.
|
||
- Versioning and releases — SemVer with `ludicc --version`, a changeset-driven `CHANGELOG.md`, and `x release` to bump, tag, and publish a Forgejo release with source and toolchain artifacts.
|
||
|
||
### CI
|
||
|
||
- Continuous integration — Forgejo Actions workflows build the toolchain from the seed, run the regression + editor suites, assert the C-free bootstrap fixpoint, and lint commit messages on every push and pull request.
|