A water body other than the reflecting one drew its whole bounding rectangle,
so the corners outside the lake's carved ellipse showed water over dry ground;
those bodies now discard outside the ellipse. r3d_fog_base (default 0) is the
height the height fog is measured from, so maps sharing one datum at different
heights get the same air.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
terrain_reload/terrain_unload release one map's height field, survey, photograph
and patch bounds and generate another at any TERRAIN_HALF; scatter_clear_all
(with streams), actor_clear_all, col_reset and mesh_free empty the scene;
water_body_add/water_bodies_clear draw several still-water planes with one
reflecting; terrain_sea separates the coast's sea level from the carved lake's,
and grass keeps off both. Fixes CDLOD patch bounds for TERRAIN_HALF != 4096 and
water_init leaking a mesh and program per call. examples/rendering/reload.ludic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The degenerate case - a point exactly on a collider's axis, where there is no
direction to push it - set the normal to (1, 0), which is already a unit vector,
and then divided it by the clamped d = 0.001 along with the genuine normals. The
push came out a thousand times too big: dead centre on a 0.5 m trunk moved a body
about 800 m rather than the 0.85 m that clears it.
Off-centre the arithmetic was right, and off-centre is how anything arrives at a
trunk on foot, so nothing in play ever hit it. A teleport, a spawn, or a world
generator dropping something onto an existing collider would have.
(ex, ez) / d is a unit vector for every d > 0, because d is its own length -
there was never anything to clamp and nothing that could grow. The normal is now
built once and explicitly, and the clamp is gone.
Verified through a game, which is the only harness this package has: render3d's
own float helpers need the Gl runtime spliced, so collide.ludic cannot be
compiled standalone for a unit test. Maroon Lake's selftest12 stands a body dead
centre on a trunk and asserts the push never exceeds the two radii added together
- which is the definition of being pushed clear, and so the tightest honest bound
available. It reports 798.88 m before this change and 0.80 m after, with the
off-centre case unchanged at 0.66 m.
`ludic bundle` builds an AppIcon.icns and macOS reads it out of the .app, so a
shipped game has an icon. `ludic build` produces a bare executable: no bundle, no
CFBundleIconFile, and so no icon at all - macOS draws the generic green "exec"
tile. That is the build a developer runs every day, which is why "the game has no
icon" can be true for months while the bundle is perfect. It was here: the .app's
icns validated against iconutil, the plist was right, the signature was right,
and NSWorkspace rendered the artwork - and the binary beside it still had the
exec tile.
App.set_icon(path) takes the bytes through lp_pak_open, so a packed path and a
loose one both work and this does not repeat Audio.load's trick of taking a
filesystem path only. NSData copies them, so the buffer goes straight back. A
missing or undecodable image leaves the existing icon alone rather than clearing
it; a bundled app is unaffected; headless links no AppKit and compiles it away.
Verified the Cocoa sequence against an ObjC twin doing the same message sends:
before, a bare binary's applicationIconImage is the generic 128x128 tile; after,
it is the 1024x1024 artwork.
Backend change, so selfhost/ludicc.seed.ll is reseeded: the bootstrap fixpoint
and the C-free rebuild from the seed both pass.
They were the macOS layout on every platform - the comment above them even said
"macOS/BSD layout" - so a game built on Linux wrote its saves to
~/Library/Application Support, a directory that means nothing there. Naming the
right directory is the entire reason a program calls these instead of joining a
path itself.
macOS save/config ~/Library/Application Support/<app>
cache ~/Library/Caches/<app>
Linux save $XDG_DATA_HOME or ~/.local/share/<app>
config $XDG_CONFIG_HOME or ~/.config/<app>
cache $XDG_CACHE_HOME or ~/.cache/<app>
macOS is unchanged to the byte, deliberately. save_dir and config_dir stay the
same directory there: Apple's home for a config file that is not an
NSUserDefaults plist is Application Support too, and a shipped game's settings
must not move out from under it. On Linux XDG separates the two and so does this.
An XDG variable that is set but EMPTY falls back to the default, which is what
the spec says and what an exported-but-unset variable looks like from a shell.
uname is read once and remembered rather than per call, because save_dir is
called on every save.
Verified on both branches. macOS by running it; the Linux branch by building the
probe's IR with the cached platform flag pinned, which exercises the emitted XDG
code exactly - unset, set, and set-but-empty all resolve as the spec says. The
suite's own case asserts the HOST's convention, so a macOS box covers the Apple
branch and CI covers XDG.
This is a backend change, so selfhost/ludicc.seed.ll is reseeded with it: the
bootstrap fixpoint (gen2 == gen3) and the C-free rebuild from the seed both pass.
A pack root is packed wholesale, and that is the right default - a game writes
`pack "assets"` and everything it opens is in the pack. What also goes in is
everything the game does NOT open: the preview renders a model pipeline leaves
beside its meshes, the intermediate a texture bake writes and never reads again,
the .blend the .gltf came out of. Nothing errors, nothing looks wrong, and the
app is simply bigger than the game. The only way out the manifest offered was
naming every file by hand, which is worse - a list that goes stale the day
someone adds a texture.
So `.packignore`, with gitignore's rules, because that is the file everyone
already knows. Anchored and floating patterns, `preview/` for directories only,
`*` and `?` stopping at a separator where `**` crosses one, `[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.
The semantics are not claimed, they are checked: the implementation was diffed
against git itself over two fixtures - 35 paths, 19 patterns, nested ignore
files, directory negation, `[!0-9]` and `\#` escaping - and `git check-ignore`
and `ludic pack` agree on every path.
Two rules of its own, because a pack is not a working tree. `.packignore` is
never packed (nothing reads one at run time, and --no-ignore does not bring it
back). And it governs the project's own roots only: a package's resources - the
renderer's shaders above all - are added after the gather, so a stray `*.frag`
in a game's ignore file cannot quietly un-ship what it needs to draw anything.
`ludic pack` reports what it left out; `--no-ignore` packs everything so you can
see what a rule is costing. `ludic bundle` gathers through the same path, so the
two agree by construction.
A frame is drawn by more than one pass - a shadow map, a water reflection, the
scene - and actor_draw runs in each. 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. A queued outline was drawn into whichever target came first and was gone by
the time the scene was drawn, so nothing appeared with every uniform, matrix
and mesh correct - which took a while to find.
A flush now closes the batch rather than clearing it, and the next
outline_model opens a new one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things a game could not say, each of which had been worked around.
Audio.play_at(id, gain:, pitch:, pan:) fires a one-shot with its own gain,
pitch and stereo position. Audio.volume and Audio.pitch are global - they are
the options screen - so a game placing a sound in the world was fighting them,
and distance attenuation was simply not expressible. The backend already took
volume and rate per call; this adds setPan: alongside them and stops routing
through the master state.
ludic.render3d gains outline_model(model, mat, width, r, g, b): a rim around
something that is not an Actor. The outline pass walked the actor list and
stopped, so instanced scatter - a forest - could not be highlighted at all. It
is a queue flushed by the same pass, which is what gets the depth test right
when the caller does not control pass order.
And terrain_coast(cx, cz, margin, fall), the other way to make an island:
the sea around the survey's own edge rather than cut out of the middle of it.
terrain_island measures a radius from a centre, which drowns two thirds of a
real survey to make an island of the rest; this measures inward from the
boundary, so everything the data covers stays land and the coast is where the
data runs out. Both modes gained a strand - the last few metres of height
either side of the water line compressed, which stretches a cliff plunge out
into beach and shallows.
132 regression tests and the self-host fixpoints pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.
Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.
A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.
The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.
Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Gl.*` binds the whole OpenGL 4.1 core API — every entry point of the
platform gl3.h with every GL_* constant, generated by `ludic-dev glgen`
with per-call ABI thunks. Windowed builds get an NSOpenGLContext on the
existing window at Retina resolution; headless builds render into an
offscreen CGL context, so a program that uses Gl.* renders and
screenshots identically under the test harness. It links gl.ll, the
thunks and OpenGL.framework only when used; every other build stays
byte-identical.
packages/ludic.render3d is a physically based renderer written on that
surface: HDRI image-based lighting, GPU-generated terrain with scanned
PBR materials, CDLOD, cascaded shadows, glTF with skinning, instanced
vegetation with impostors, procedural grass, water, SSAO, and an HDR
pipeline with bloom, auto-exposure and ACES.
It also carries this session's work on it: the terrain at half its cost
(10.3 -> 5.4 ms of frame), the streaming hitch that got worse the longer
you played, a resize that emptied the world, and the packaging that lets
a game use the renderer from its own repository — `ludic assets`, the
material manifest shipping with the package, and shader lookup falling
back to the install root. See changes/ for each, with its numbers.
The camping game that drove all of it has moved out to its own
repository, Maroon Lake; examples/rendering/smooth.ludic stays as the
renderer's example here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The install root is not only the artifact: `ludic add` caches fetched packages
in <root>/store, keyed by content hash. install_staged moved the whole root
aside and replaced it, so re-running the installer — which is exactly what
`ludic upgrade` does — deleted the cache and forced every project to refetch.
The store is moved into the staged tree before the swap. Nothing else in the
root is preserved, because everything else is the toolchain and should be
replaced.
Verified by staging an install, planting a store entry, upgrading, and reading
the entry back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic new my-game` wrote `program My-Game`, so the first thing anyone did with
a new project — run it — failed with `expected '{', got '-'`. The project name
is a directory name and the identifier is Ludic source, and they do not accept
the same characters: names are now folded into a valid identifier (my-game ->
MyGame, 2048 -> Game2048, a.b.c -> ABC) and a name that cannot be a directory or
a package is refused with the rule instead of being mangled into one.
An audit of every command's flags turned up more of the same shape, all fixed:
- build/run ignored unknown options, so `--headles` silently produced a windowed
binary, and `-o` with no path was silently dropped.
- `ludic fmt` printed the formatted text to stdout while its help said "in
place", so it appeared to do nothing. It writes now, with --check for the
report-only case a hook wants.
- `ludic test nosuch.ludic` deferred the error to the compiler.
- build-lib's failure messages ran `{tmp_dir()}` through the shell literally —
an interpolation written inside a non-interpolating string — and its argument
guess matched package.lock.ludic, then tried to compile the lockfile.
- Several messages still identified the tool as `x`.
`ludic-dev test` now scaffolds under four awkward names, builds and tests each,
and asserts a space-bearing name is refused — the case that shipped broken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic help` ended with a section titled "contributing to the toolchain itself",
listing bootstrap, reseed, docs-gen and release tasks. None of that is available
to someone who installed the language — those tasks need the repository — so the
shipped tool was advertising work its user cannot do, in a namespace they have to
read past to find `new` and `run`.
The tasks move to a second program, dev.ludic -> bin/ludic-dev, built from a
checkout and excluded from every release artifact. `ludic` keeps the project and
package commands and nothing else; `ludic dev …` now explains where the tasks
went instead of failing as an unknown command.
What this shook out: the two programs share prelude/build/project/pkg, so the
helpers each had accreted in whichever file first needed them — cc(),
ensure_ludicc, the string functions, title_case, cmd_version — moved to where
both can see them. The argument-shift indirection added for the `dev` namespace
is gone with the namespace, so commands read argv directly again.
`ludic-dev test` asserts the split rather than trusting it: the staged install
must build a project, and `ludic dev build` there must fail while naming
ludic-dev. install.sh keeps building older tags, whose bootstrap goes through
main.ludic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs-gen publishes install.sh with the site, but the workflow only ran for
docs/**, tools/docgen/** and tools/ludic-cli/**. v0.5.2 changed install.sh,
CHANGELOG.md and VERSION — so nothing matched, no deploy ran, and the fix that
release existed for was never served to anyone running the one-liner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~/.bashrc was only appended to when it already existed, so on an account without
one — a fresh container, a minimal image — `bash -i`, which is what most Linux
terminal emulators start, did not see the toolchain even though every other
shell did.
.bashrc is now created when missing. Unlike .bash_profile, whose existence stops
bash reading ~/.profile, a .bashrc that only sources the env file changes nothing
else about how bash starts.
Verified across zsh -i, zsh -c, bash -l, bash -i, sh -l and dash -l on a staged
HOME: all six resolve ludic, a second run writes nothing, and .bash_profile is
still never created.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The installer edited one profile — whichever ~/.zshrc or ~/.bashrc $SHELL
pointed at — and skipped any profile that did not already exist. So a fresh
account got nothing written at all, a bash user's ~/.bashrc is not read by the
login shell macOS Terminal starts, and ~/.zshrc is only read by interactive zsh.
The toolchain installed correctly and `ludic` was still not a command.
The PATH edit now lives in one file, <install>/env (plus env.fish), and each
profile gets a single line that sources it: ~/.profile for sh and for login bash
with no .bash_profile, ~/.zshenv because zsh never reads ~/.profile and reads
this one for every invocation, ~/.bashrc and ~/.bash_profile when they already
exist, and fish's config when fish is installed. Missing .profile/.zshenv are
created; .bash_profile deliberately is not, since creating it would stop bash
from reading ~/.profile at all.
Sourcing a shared file rather than appending an export keeps a re-install from
accumulating a second entry, and leaves one place to delete when uninstalling.
Verified with a staged HOME: zsh -i, zsh -c, bash -l, bash -i and sh -l all
resolve ludic; a second run reports "already on your PATH" and writes nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ludic version` looked for bin/ludicc and VERSION relative to the working
directory. In the toolchain repo that is right by accident; from a project — the
only place a user runs it — there is no ./bin, so a perfectly good install
answered "(version unknown)". It now resolves the compiler through
ludic_home(), like every other command.
The regression test asked for the version from the repo root, so it passed for
the same accidental reason the bug hid behind; it now asks from the staged
project, where the answer can only come from the install.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Getting started meant cloning the repository, bootstrapping a compiler and
learning a task runner called `x`. That is a contributor's workflow handed to
everyone who wants to try the language.
Installing is now one command:
curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | sh
install.sh puts a complete toolchain — compiler, CLI, engine runtime, bundled
ludic.* packages, formatter, language server — in ~/.ludic and adds it to PATH.
Prebuilt artifacts are checksum-verified; where a platform has none, or the
release predates this layout, it bootstraps from the compiler's own IR seed with
clang. The docs site publishes the script beside the pages that quote it, so the
page and the script can never come from different releases.
`x` becomes `ludic`, and the surface splits by audience. A user of the language
sees `new`, `run`, `build`, `test`, `add`, `fmt`, `lsp`, `doctor`, `upgrade`;
`ludic new` scaffolds a project that builds and plays as it stands. Everything
the toolchain repo needs moved under `ludic dev` — build, test, reseed,
bootstrap-cfree, docs-gen, release — unchanged apart from the namespace. Those
tasks read arguments one position further along, so dispatch_dev sets a shift
and commands use arg_n()/arg_total() rather than each knowing its own depth.
Release artifacts become complete install roots (bin/ beside runtime/, packages/
and VERSION) rather than bare binaries, which is what the installer unpacks.
`ludic dev test` asserts the whole shape: it stages an install, puts it on PATH
with no LUDIC_HOME, and runs new -> build -> test through it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a keyword, type, builtin or namespace method in a code sample is
supposed to open a summary card for it. The script that builds those cards
never went away; its stylesheet did.
The site redesign split item.css into base.css and docs.css and filed the card
chrome — .hovercard, .hc-*, .tok, .kind-badge — under docs.css. Only the four
reference page kinds link that file. The landing page links base.css and
site.css, so a click there appended an unstyled, position:static div to the end
of the document: built, filled with the right text, and invisible.
The card chrome now sits in base.css beside the .t-* token colours it belongs
with, which every page links. It is also position:fixed rather than absolute,
matching the viewport coordinates positionCard() reads out of
getBoundingClientRect() — absolute put the card an entire scroll offset away
from its token on any reference page read past the fold.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README opened with three restatements of "no C", then a limitations table,
then a repo-layout map, and closed with Chrono Rift's keybindings — P2/Mage
`I`/`K` select, `J` confirm — which is a game manual, not a language README. It
never showed the language itself.
It now leads with what Ludic is, a compiling code sample, how to build, what the
language offers, and an honest status. The layout table is contributor material
and CONTRIBUTING already covers that ground.
Two things were not merely stylistic:
- Nine links pointed at wiki pages that no longer exist.
- "Language at a glance" advertised `system` with `reads`/`writes`, plus
`requires`/`ensures`. None of those are keywords — the grammar has `handler`,
and `System.*` is a namespace. That bullet described a vocabulary retired
several releases ago.
The sample is verified to compile, every internal link resolves, and every
command named is one `x help` actually offers.
Also makes commit-lint survive a force-push: it linted `event.before..sha`
without checking that `before` still resolves, so rewriting or gc'ing that
commit failed the job with "Invalid revision range" on a push whose messages
were all valid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_artifacts wrote a single dist/SHA256SUMS covering whatever that host
happened to build. But a release is assembled from more than one machine — the
Linux runner cannot produce the darwin-arm64 toolchain — and
forgejo_upload_assets deliberately skips an asset whose name is already
attached. So the first host to publish wrote SHA256SUMS, and every artifact
added later was silently left uncovered by it.
Emit one <artifact>.sha256 per tarball instead. The names are unique, so each
host's contribution stands on its own and nothing goes stale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Publishing the site force-pushes an orphan `pages` branch. Two runs racing can
therefore land out of order and leave `pages` holding the older build, with both
runs green and nothing to indicate the site went backwards.
A `pages-deploy` concurrency group with cancel-in-progress makes a newer push
cancel an older in-flight build rather than queue behind it, so the last push to
land is the one that ends up published.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every workflow's first step clones ${{ github.server_url }}, which on a
self-hosted Forgejo instance is an internal address like http://forgejo:3000.
The runner's default is to put each job on a freshly created per-job network
that the Forgejo container is not attached to, so the clone dies with
fatal: unable to access 'http://forgejo:3000/…': Could not resolve host
Nothing in the repo said so, and the failure is intermittent: Docker forwards
names it cannot resolve to the host's resolver, which answered for the container
name often enough that CI passed for weeks before stopping.
Documents the fix (pin job containers to a network Forgejo is also on, using a
dedicated one rather than the general application network so a CI job cannot
reach unrelated services) and how to verify it without running a workflow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each clang invocation the task runner makes emitted
warning: overriding the module target triple with arm64-apple-macosx15.5.0
four times per `x build`, with nothing for anyone to act on. `cc()` now passes
-Wno-override-module, the same flag ludicc already passes for its own link.
The comment in selfhost/main.ludic explaining that flag had it backwards — it
claimed "our IR carries an explicit target triple", when the emitted IR names
no triple at all, which is precisely why clang substitutes the host's and
warns. Corrected. No IR changes, so the seed is untouched.
Also adds .claude/launch.json entry for previewing the generated docs locally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bin/ is gitignored and not checked in, so the first command in README.md,
COMPILING.md, CONTRIBUTING.md, tools/x/main.ludic and on the site —
clang selfhost/ludicc.seed.ll -o bin/ludicc && ...
failed on a clean checkout with `ld: open() failed, errno=2 for 'bin/ludicc'`.
Verified against a fresh clone. Every copy now leads with `mkdir -p bin`, which
is what the CI workflows had been doing all along.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was no release workflow. Artifacts were built by `x release --publish` on
whatever machine the maintainer was sitting at, from whatever happened to be in
bin/, with no checksums and nothing proving the tagged tree passed its tests.
Pushing a v* tag now publishes. The workflow 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 when CHANGELOG.md has no section for that version.
`x publish [vX.Y.Z]` is the command behind it and runs locally too. It builds
dist/ — a source tarball from the tag, this host's toolchain, and a SHA256SUMS
covering both — and takes the release notes from that version's CHANGELOG
section, so notes and changelog cannot drift. It only adds assets the release
is missing, which is how a macOS build gets attached to a Linux-built release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_section piped every changeset body through `tr '\n' ' '`. A multi-line
changeset came out as one paragraph, so nested bullets rendered as inline
" - " runs and a whole release read as a single unbroken block — v0.3.0 was one
~4 KB bullet.
The renderer is now Ludic rather than a shell one-liner. A section is grouped
by conventional-commit type (Features, Fixes, Performance, ...), each changeset
is one bullet, and continuation lines are indented two spaces so nested lists
and paragraphs stay inside their item. Bullets are sorted within a group, so
cutting the same release twice produces the same text.
Also:
- `x release --dry-run` renders the pending section to stdout and touches
nothing, so a release can be read before it is cut.
- `x changelog-render` re-renders a section from a directory of changesets, and
`x changelog-section` prints one release's section back out of CHANGELOG.md.
- The v0.1.0 and v0.3.0 sections are re-rendered with the former, from the
changesets recovered at each tag's parent commit; the bullet counts (6 and
25) and word multisets are unchanged. v0.2.0 is left alone: it carries a
hand-written summary and topical subheadings, and regenerating it would have
replaced curation with raw changeset dumps. The file header now says that
a section may carry such a summary, since it previously claimed released
sections are never hand-edited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The generated site had the shape of a product launch page: a near-black navy
ground with mint/coral radial glows, a gradient-clipped headline, a glowing
pill badge, nine emoji feature cards and scroll-reveal animations. None of it
told a reader anything about the language.
It is now typographic and light-first — a warm paper ground, a serif display
face, one ink-blue accent used only where it means something, and rules
instead of floating cards. Colour is reserved for code. Dark mode is the same
design with the ground inverted, defined once as tokens under a single
prefers-color-scheme block.
Structurally:
- base.css holds the tokens and shared chrome; site.css and docs.css hold what
is specific to the landing page and the reference pages. They ship as linked
files rather than being inlined into all 900+ pages, which takes the site
from 16 MB to 5 MB and means a design change no longer needs a regenerate to
be seen.
- api.css was dead — the generator never referenced it, rendering the API index
with item.css — and is gone. docs.css replaces item.css and covers all four
reference page kinds.
- Grids draw their separators as cell borders instead of bleeding a ruled
background through gaps, so a final row with fewer cards than columns stops
cleanly instead of leaving a grey hole. The feature card count is not a
multiple of the column count at any breakpoint.
- Inline code loses its tinted chip; in a language reference, a box behind
every keyword turns a paragraph into confetti.
- Fonts are the platform's own, so the site makes no webfont request.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `[a, b, c]` list literals (E_LIST → emit_list); static_type learns
slice-element, `new T`, list, string and literal kinds
- `x op= y` lowers through the same path as `x = x op y` (emit_bin_vals):
fixed `*=`/`/=` use the Q16.16 64-bit paths, string `+=` concatenates,
int→long widens; unary `-` keeps a fixed operand's type (arith_ty)
- one `unescape()` table for "strings", 'chars' and `interpolation`;
`'\''`, `'\\'`, `'\"'` no longer read as 0; unterminated char literals
and unexpected characters are errors instead of silently skipped
- every diagnostic is `file:line: error: msg` (g_parse_file / g_err_file,
Node.file + Node.line set by node()); tok_desc() in expectation errors;
duplicate `function` names and unknown `phase` names are reported in
source terms (phase_id used to default unknown phases to Overlay)
- interpolation holes skip braces inside string literals
- hand-IR preludes move from the user `@fn_` prefix to `@lp_` so a user
`is_ws` / `str_eq` / `path_join` no longer collides at link time
- `@ClearColor(expr)` accepts any constant expression; `Os.pid()` added
(docs page + inventory); `str_starts()` in support/str
- main.ludic: `else if` flag ladder, char literals, stale script comments
- examples/lang/operators.ludic covers all of the above; os.ludic covers
Os.pid; docs pages for Os.pid and the Overlay phase; ten changesets
- reseeded: selfhost/ludicc.seed.ll is the new compiler's own fixpoint
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Closes the two open issues and lands the pending unreleased batch:
- #90: `Sprite { atlas: 1 }` routes esys_sprite through atlas_draw_ex
(scale/flip/tint), so cell / cell_span / strip ids of any size draw
through the engine sprite-render system. examples/library/sprite_atlas
is the pixel-readback regression.
- #91: `become` from an @On(Event) listener / global handler / plain
function no longer segfaults the compiler; it emits @L_scene_leave()
(a dispatch on the live scene id) so the leaving scene's on-exit runs.
UI_* handles are readable from any code (widget table built on first
use). examples/library/scene_menus covers it.
- fix: a windowed `ludicc -o` build that reaches the audio runtime only
through the atlas/Assets preload import now links audio.ll +
AVFoundation (the audio backend link was gated on a game-level
Audio.* call, so any windowed game declaring Sprite failed to link).
- the hand-written "Unreleased" CHANGELOG section is converted to
changesets under changes/ so `x release` generates it.
- plus the batch: engine-driven retained UI + UiClicked event, Overlay
phase, TileSkin tilemap-render system, Key.* constants, Font/Ui/File
namespaces, Sprite.strip, prefabs, managers, countdown fields,
enum-typed machines, layer @Queries, ludic.prefs / ludic.dungeon
packages, Ai.seek pathing, Solids.solid2, cursor confine (mode 3)
fix, shooter centre-aim fix, reserved-word function diagnostic.
Verified: x test (124/124), x test-tools, check-impl, check-vocabulary,
check-docs, docs-gen + docs-check, bootstrap-cfree (seed is a fixpoint).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ludic's ECS is already pool-based — the allocator recycles freed entity slots
through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
storage is fixed per-entity arrays, so spawn/despawn churn (bullet-hell/horde)
does no per-spawn heap allocation and cannot fragment. Expose that with a Pool.*
namespace so a game can watch reuse: Pool.live (alive now), Pool.free (recycled
slots waiting), Pool.reserved (high-water — stays flat across a steady
spawn/despawn loop, proving reuse not reallocation), Pool.capacity (the fixed
cap). Zero-cost inline reads of the existing counters.
Example pool.ludic proves the key property: after despawn+respawn,
Pool.reserved() stays 3 (freed slot reused) — prints 0 0 3 3 0 2 1 3 0 3 1.
4 docs pages. Full suite 118/0, goldens byte-identical, fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assets loaded synchronously in Boot stalled the first frame(s). Adds an
Assets.* preload queue over the #81 atlas: Assets.enqueue(name, path) queues a
named image without loading it, Assets.pump(max) loads up to max per frame
(returns how many), and Assets.total/loaded/ready/progress (0..100) drive a
progress bar. A loading scene pumps a few per frame, draws Assets.progress(),
and becomes the play scene once Assets.ready() — the deterministic, no-threads
form of async preloading (work spread across frames; same enqueue+pump order
loads identically every run). Loaded assets are reachable by name via
Assets.get / Sprite.named.
Example preload (enqueue 3, pump incrementally 0->33->66->100, ready flips, get
by name) prints 3 0 0 0 1 33 66 1 100 1. 6 docs pages. Full suite 117/0,
fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`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). Inside the block
each `function short(...)` is emitted as `namelower_short`; export (the default)
makes it callable as Name.short(...), internal keeps it a private helper
(emitted, callable by short name from siblings — calls are rewritten — but
Name.internalOne() is a compile error). Block sugar for the per-function
@Namespace annotation; a package's public API reads at a glance. Namespaces
declared the old way are unchanged (gameplay_foundation still passes).
namespace added to LUDIC_KW_DECL + JetBrains/TextMate + docs page + inventory
(vocab/impl/docs checks green). Example namespace_block (export + internal +
sibling calls + internal-visibility compile error verified). Full suite 116/0,
fixpoint holds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>