63 KiB
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.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_profileis 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 versionreports the version of the toolchain it belongs to. It looked forbin/ludiccandVERSIONbeside 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.shalso now putsludiconPATHfor every shell, not just the one$SHELLnames. The PATH edit lives in one file (~/.ludic/env) that each profile sources, and the profiles are chosen to cover what people actually open:~/.profilefor sh and login bash,~/.zshenvbecause zsh never reads~/.profile,~/.bashrc/~/.bash_profilewhen they already exist, and fish's config when fish is installed. Re-running the installer does not add a second copy, and--no-modify-pathstill touches nothing.
v0.5.0 — 2026-09-05
Features
-
One command installs Ludic, and
ludicis the command you use. Getting started no longer means cloning the repository and learning a task runner calledx.curl -fsSL https://workshopsoft.pages.workshopsoft.io/ludic/install.sh | shinstalls a complete toolchain — compiler, CLI, engine runtime, bundledludic.*packages, formatter and language server — into~/.ludicand puts it on yourPATH. 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 isrm -rf ~/.ludic, andludic upgradere-runs the same script.ludicreplacesxand is the only command a user of the language meets:ludic newscaffolds a project that builds and plays as it stands,ludic run/ludic buildcompile it (--headlessfor a deterministic render),ludic testruns everytestblock in the project, andludic add/get/update/verify/vendordrive packages.ludic fmtandludic lspare the formatter and language server, so an editor needs no path configuration.ludic doctorreports 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 releaseand the rest are unchanged apart from the namespace.bin/xis gone; the bootstrap is nowclang 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~/.ludicand a repo checkout are the same shape, and$LUDIC_HOMEis no longer needed to build a windowed game outside the repo. The bundledludic.*packages resolve from$LUDIC_HOME/packages, soimport "ludic.core/components.ludic"works with noludic_modules/to set up. Release artifacts are complete install roots (bin/besideruntime/,packages/andVERSION) 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.cssduring the site redesign, so on the landing page — which links onlybase.cssandsite.css— the card rendered unstyled at the foot of the document instead of beside the token. It now lives inbase.csswith the rest of the highlighter chrome, and is positionedfixed, 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
.sha256file per artifact instead of a singleSHA256SUMS. A release is assembled from more than one host — a Linux runner cannot build the macOS toolchain — andludic dev publishnever overwrites an asset that is already attached, so a sharedSHA256SUMSwas written by whichever host published first and then never covered anything added afterwards. Per-artifact names compose across hosts. Verify one withshasum -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,requiresandensures, none of which are keywords; the declaration keyword ishandler.
CI
- The commit-lint workflow survives a force-push. It linted
${{ github.event.before }}..${{ github.sha }}without checking thatbeforestill resolves, so rewriting or garbage-collecting that commit failed the job withfatal: Invalid revision rangeon a push whose messages were all valid. It now falls back to linting the tip commit whenbeforeis gone. - The docs deploy is serialised. Publishing is a force-push of an orphan
pagesbranch, so two runs racing could land out of order and leave the site holding the older build — with both runs reporting success. Apages-deployconcurrency group withcancel-in-progressmeans 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: FoewhereFoeis a prefab) so shared presets live once.spawnis now also an expression yielding the new entity (let e = spawn Grunt { … }), andPrefab.spawn(name: "Grunt")spawns one chosen at runtime by name (-1 when none matches). countdownfields. A component field declaredframes_left: countdown = 0is anintthe 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
machineover an enum-typed store maps its states to the enum by name: withenum HeroState { Idle, Rolling }andvar hero_state: HeroState = HeroState.Idle,machine hero_state { state Idle { … } state Rolling { … } }dispatches onHeroState.Idle/HeroState.Rolling— nostate Idle = HeroState.Idlerepetition, and a state that names no variant is a compile error. A bare enum is now a first-classint-sized type forvar, params, fields and returns (llty), andEnum.Variantfolds in a global initializer. - A handler inside a scene
layermay 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
varmay 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 theStartphase), in declaration order. Previously such an initializer was silently replaced by0/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.enqueuenow loads.wav/.mp3into the sound bank and.ttf/.ttcinto 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 onefunctiontwice is reported in source terms instead of failing in the IR assembler. - Less to write for a game.
Mapcell 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 theSolidsconfig) instead of keeping its own grid.Spritedoes the small animation work:move_idis the strip drawn while the entity moves,face: 1turns it toward its movement, and theflash/blinkcountdowns 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/stepandAngle.diff_degreescover the geometry every action game rewrites;List.sampledraws distinct random picks;Input.move_iis the standard top-down movement intent;AimMode.Autoaims with the mouse, or the right stick while a pad is connected;Weapon.set_rate/Weapon.ratechange a fire rate in place; projectiles now die onSolidstiles by themselves;Screen.bardraws a meter. Handlers inside a scene's layers are scene-qualified (Play_Draw), so two scenes may both name a handlerDraw;enable/disableof 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 }forInput.bind_pad(button:). - The second "write less" round, all generic. ludic.gameplay:
Statscarries the build stats every action game bolts on —damage_pct,crit_pct,leech_pct/leech_hp,thorns,fire_rate_pct— andCombat.damageapplies them itself (aCritevent fires; thorns never reflect thorns);Stats.add(e, stat, amount)changes a base stat in place andStats.scale_hp(e, percent)scales hp and max_hp;StatKindnames every code. ludic.shooter:Dash { frames, speed, cooldown_frames }withDash.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 }withMelee.swing(e)(hits every hostile in the arc, knocks back, firesMeleeHit) /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 honoursStats.fire_rate_pct. ludic.dungeon (new package):Dungeon.arena / open_arena / random_style / set_exit / entry_point / opposite / at_edge, withSideandRoomStyle— 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. Alsoscene X loads then Y(the loading scene: pumped, drawn,AssetsReadyfired),Prop.despawn_all(),Map.to_tile, andBrain { 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;uiwidgets inheritfont/size/fg/alignfrom their panel; the input runtime namesMouseButton { 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-awarebrain_seek— when a Solids tilemap is present the NPC-AI routes a blocked straight line around obstacles withGrid.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 likeColor.*; andFont.*/Ui.* / File.*namespaces sopng_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.Overlayrender 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)andProp.has(entity)— typed access to one entity's component from an entity handle, the same binding a query loop makes.Hero.of(player).iframes = 20reads and writes fields directly (noWorld.prop_id/World.field_id/World.getreflection chain);Prop.has(e)is true wheneis in range, alive, and carries the property, so-1is a safe "no entity". A package that declares a realprop_of/prop_hasfunction 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 forSpriteAnim).Spritecomponent draws atlas ids (#90) —Sprite.atlas = 1routesesys_spritethroughatlas_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 ofUi.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 (usenew []T). Tables of records read as[Row { … }, Row { … }].ludic.prefspackage —Prefs.*, a human-readablekey=valuetext store for scores / options (the right tool for "remember my best run"; a whole-worldSave.writeis not).v.xandv.yread the components of anIVec2value (a local, a global, a record field, or a call result) — the readable form ofIVec2.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@Computedfields expand in those positions too.- engine tilemap-render system (
TileSkin, shipped from ludic.core) — one entity per glyph paints the wholeMap.*grid each Render frame before sprites, so a game stops hand-looping the map.esys_tileskinregistered ahead ofesys_sprite. - engine-driven retained UI — a program with a
uiblock has its navigation ticked by the frame loop automatically (from the frame key) and an activation now emits aUiClicked { id }event, so scenes react with@On(UiClicked)instead of pollingUi.clicked. NewUi.*namespace (Ui.open/tick/clicked/set_text/render/build).
Fixes
-
A
UI_Namehandle 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_buildis emitted, which came after functions and listeners and crashed the compiler on such a reference. -
A
vardeclared twice — including a gamevarwhose 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 coverspropertynames (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/.mp3into the sound bank) or the engine sprite-render system, without anyAudio.*call in the game — now links the native audio backend (audio.ll+ AVFoundation). Previouslyludicc -ofailed at link with undefinedsnd_*symbols for any windowed game declaring aSpritecomponent; the import ofruntime/native/audio.ludicnow 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 userfunctionnamedis_ws,str_eq,path_joinand the like no longer collides with them at link time. -
Named arguments now work on namespace functions (
@Namespace(Foo)andnamespace 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, soclang selfhost/ludicc.seed.ll -o bin/ludicc— the first command in the README, in COMPILING, in CONTRIBUTING and on the site — failed withld: open() failed, errno=2 for 'bin/ludicc'. Every copy now begins withmkdir -p bin, which is what CI had been doing all along. -
Unary minus keeps its operand type:
-fon afixedis afixed(it was typedint, which broke mixed arithmetic and comparisons). -
become Scenenow 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 itson exit— and calls it there. Previously a listener'sbecomereused the last emitted handler's scene (or crashed), and a global handler'sbecomeskipped the leaving scene'son exitentirely. -
ludic-fmtkeepsrows[i],new []int,s[a..b],emit(…),~xand list-literal braces tight, and recognises<< >> & | ^ ~as operators. -
ludic-fmtno longer glues an opening parenthesis to a preceding operator:let moving = (a or b)stays as written instead of becominglet 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 likex = x op y: a Q16.16fixedmultiplies and divides through the 64-bit path, a string+=concatenates, and anintadded to alongwidens (they previously emitted raw integer arithmetic on the LLVM type). -
x releasekeeps a changeset's markdown intact. Bodies used to go throughtr '\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-runrenders 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 fromCHANGELOG.md;x changelog-renderre-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 likemode 2(lock/relative). Only true-lockmode 2uses relative deltas now;win_mousereports the absolute position onmode 3and clamps it to the framebuffer. Andmode 3now 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 whileaim_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-schemeblock, 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.csswas dead — the generator never referenced it — and is removed along withitem.css, whichdocs.cssreplaces.- A page no longer flashes its own title on every plain visit; only a deep link
highlights its target, and under
prefers-reduced-motionthe 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/xdoes not exist on a clean checkout.
- Stylesheets are linked files (
Build
xno longer prints a clang warning on every build. Eachclanginvocation the task runner makes now passes-Wno-override-module, the same flagludiccalready passes for its own link step: the emitted IR names no target triple, so clang substitutes the host's and says so — four times perx build, with nothing to act on. (The comment inselfhost/main.ludicclaimed 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
releaseworkflow triggers on av*tag, builds the toolchain from the IR seed, runsx test,x test-toolsandx bootstrap-cfreeagainst the tagged tree, and only then creates the Forgejo release. It refuses to publish when the tag andVERSIONdisagree orCHANGELOG.mdhas no section for that version.x publish [vX.Y.Z]is the command behind it and works locally too: it buildsdist/(a source tarball from the tag, this host's toolchain, and aSHA256SUMScovering both — releases previously shipped no checksums) and takes the release notes from that version'sCHANGELOG.mdsection, 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 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 underassets/tiled-fixtures/. - P0.5 TMX/TSX reader (#68) —
Tiled.read/Tiled.read_tsxmap the native XML formats onto the same intermediate the JSON path produces — aValuetree 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.tmxand a base64+zlib.tmjof the same map read structurally identically; the in-repo KenneysampleMap.tmx+ externalsampleSheet.tsxload with no manual JSON re-export. - P1 core load + render (#69) — the runtime
rt_tmapmodel (heap-allocated tow·h, lifting the old96×64cap), 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 soGrid.*/Path.*/esys_movekeep working.Tiled.loadreads either format, resolves external tilesets + images, and auto-projects acollisionlayer. The Kenney sample loads and renders pixel-identically from.tmxand.tmj; thegridandphysics_tilesdemos 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, thesolid/oneway/triggerproperty 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_starover 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_animdraws a map with animations advanced. Object-layer entries with agidrender 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_typesoverobjecttypes.xml); template instances inherit their.tx/.tjtemplate'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 JSONchunks[]decode and flatten into the dense layer;.worldstitching (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).
- P0 parsing primitives (#67) — a minimal pure-Ludic XML reader (
-
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'sTopDown.aim_mode = 3) and a companion reuses the mover — friendly vs enemy is faction + goal, not different code. Perception (Vision+Memory, throttledesys_perceptionwith faction filtering and optionalGridline-of-sight) remembers the nearest hostile and emitsTargetSpotted/TargetLost. Decision offers three models writing oneBrainintent — a finite-state machine (patrol/chase/attack/flee), a utility scorer, and a canonical behaviour tree — each decision veto-able viacancellable DecisionMade. Steering adds Reynolds flocking (separate/cohere), and aFollowercomponent 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 sharedesys_movesweep) and animation-state (LateUpdate, after it) — each independently switch-off-able withdisable 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 acancellable JumpRequested/JumpPerformed/Landed/StateChangedevent, and a gravitypolicyenum (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 lightLife/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 inludic_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
Moverwith amodeselector (grid / free / grid-tween) and 4/8-axis, tilemap walkability, andcancellable MoveRequested(locked doors/ice) /TileEntered(encounters) /Interacted(the action-button raycast). B Inventory — a name-keyed item registry, per-owner counts, gold,ItemUseveto, andEquipmentwhose bonuses flow through the gameplay Stats modifier stack. C Crafting — a data-driven recipe + ingredient registry;Craft.can/Craft.makeconsume from the inventory. D Quests — quests + objectives whose progress is driven byQuest.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-speakerDialogcomponent andcancellable DialogChoicefor skill-check gating. F Puzzles — SokobanPushable+ 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.
TopDowndecouples movement from aim (aim_mode: mouse / right-stick / move-direction / nearest-enemy auto-aim, with aturn_ratefor 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_weaponreads awant_fireintent so the same weapon fires for a player (input) and an NPC (AI).Projectile+esys_projectileis a self-contained deterministic pool: integrate, TTL, faction-filtered hit throughCombat.damage, pierce, and homing that curves onto the nearest enemy — every step observable viaProjectileSpawned(mutable/veto) /ProjectileHit/ProjectileExpired. A budgetedSpawnerwave director emitsSpawnRequested/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/Solidsbundles 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 gameimport "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_savehold). Example:examples/library/core_components.ludic. -
Cursor capture (#89) —
Input.cursor_mode(mode)hides / locks / confines the OS mouse for a windowed game:0normal (visible, free),1hidden (hide the OS cursor while focused so a game draws its own reticle),2locked (hidden + dissociated — the mouse feeds relative motion throughInput.mouse_dx/dy, andInput.mouse_x/ybecomes a clamped virtual cursor, the FPS / twin-stick aim mode),3confined (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 incocoa.ll([NSCursor hide]/[unhide], ref-counted and toggled only on change;CGAssociateMouseAndMouseCursorPosition;CGGetLastMouseDeltafor the relative virtual cursor) behind a newwin_cursor_modeintrinsic; 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 repeatsScreen.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@ClearColoris 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 withCamera.set/follow/shake, and it is a render-time transform — the world coordinate types stay integer pixels + Q16.16 velocity, so lockstep, replay andworld_saveare untouched, and the zoom itself is deterministic. Gated by an internalrt_cam_zoomedflag 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) -> intreturns a -1/0/1 movement intent (+1positive key held,-1negative,0neither or both) read from the multi-key device set, so turning WASD into movement no longer needs theki(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'). ComplementsInput.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
Spritecomponent (id + optional offx/offy/scale/flip/tint/hidden, shipped from ludic.core) on an entity with aPositionand the engine draws it each Render frame — no hand-written Render handler querying positions and callingdraw_spriteper entity, and no hand animation (when the entity also carriesSpriteAnim, 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 declaresSprite, so a game that never declares it compiles byte-identically; a game wanting a custom draw omitsSprite(ordisable system esys_sprite). Also deprecates the baredraw_sprite/draw_sprite_scaledglobals in favour of the namespacedScreen.sprite/Screen.sprite_scaled: a direct bare call now emits a one-time compile-time deprecation note (the bare form still lowers, sinceScreen.spriteuses it), and the in-repochronoriftdemo 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
despawned slot is reused by the nextspawnbefore 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 aPool.*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), andPool.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
Cooldownframe timer (engine-ticked), aStatsattribute bundle with an unbounded timed modifier stack (Stats.totalcomputes base+flat then percent on demand; expired modifiers self-despawn), aFactionfriend/enemy/neutral relationship table (same-id-friendly / different-hostile by default), and aCombatdamage pipeline whosecancellable DamageAboutToApplyhook lets a game veto a hit or rewrite the amount (Combat.set_amount) and which emitsDamaged/Died/Healed. Everything is integer-only so lockstep, replay andworld_savesnapshots 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 anAssets.*preload queue:Assets.enqueue(name, path)queues a named image without loading it,Assets.pump(max)loads up tomaxqueued assets per frame (returning how many it loaded), andAssets.total/loaded/ready/progress(0..100) drive a progress bar. A loading scene pumps a few assets per frame, drawsAssets.progress(), andbecomes the play scene onceAssets.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 viaAssets.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_polleach frame (reading the live key, recording/replaying, and rebuilding the held-key/mouse/gamepad state), soInput.active/Input.key_down/ the mouse and pads read live without the game callingInput.pollby hand (previously the loop fed onlyInput.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 plainrt_pollpath, byte-identical. Adds the Input-Manager API on top:Input.action(name, key)ships a default binding (kept if already bound, so a player'sInput.rebindor a loaded key-map is not clobbered);Input.bind_pad(name, button)makes an action device-agnostic (fires from keyboard or gamepad); andInput.active/Input.just_pressed/Input.just_releasedread 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 aName.*namespace once and controls its public surface declaratively, instead of annotating every function with@Namespace(Name)one at a time. Inside the block eachfunction short(…)is emitted asnamelower_short; anexportfunction (the default) is callable asName.short(…), while aninternalfunction 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 theName.*surface (Name.internalOne()is a compile error). It is the block sugar for the per-function@Namespaceannotation, 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 aSprite.*/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)andSprite.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, likeScreen.sprite),Sprite.width/height, andAssets.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
Boundsconfig entity (a rectx, y, w, hplus a policy, shipped from ludic.core) and the engine-owned world-bounds system keeps every movingBodyinside the play area each frame, so a game no longer hand-clampsPosition. Four policies:0clamp (walls),1wrap (toroidal),2bounce (clamp + flip the Body velocity on the axis that hit),3kill (despawn a body fully outside). It reads each body'sCollidersize so the whole box stays inside; off by default (noBoundsentity = open world). Registered on the engine-system registry for LateUpdate (after movement integrates) and spliced only when a game declaresBounds, so a game that never does compiles byte-identically. Also addsWorld.despawn(entity)— the reflective, by-id form of thedespawnstatement (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 vendorbring third-party packages to Ludic with no new infrastructure. Dependencies are named by their git import path (URL-as-identity, no registry — agit tag vX.Y.Zis 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 underludic_modules/. Apackage.ludicmanifest declares dependencies, the providedFoo.*namespace(s), the kind (source or prebuilt) and, for prebuilt libs, the shipped targets;package.lock.ludicpins 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, defaultludic_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 aName.method(…)namespace that dispatches to the barename_methodthrough 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>/); akind prebuiltdependency is fetched and linked like any other, andx link-flagsprints the clang flags to link the module dylibs into a game (orx appdoes 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; aruntime/...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.llfiles already come from) before the package module root. So an external game that consumes theludic.*packages no longer has to copy or symlink the engine runtime into itsludic_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_HOMEfallback 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_addrset the globalg_addr_tyto 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 setsg_addr_tylast, matching the raw-pointer branches. Also de-duplicate the@strcmpdeclaration (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 calledInput.pollby hand committed a second time in the same frame, andinput_device_commitcopiesin_heldintoin_prevat the top of every commit — so the second commit leftin_prev == in_heldandkey_pressed(held && !prev) /key_releasedcould never see a transition. Fixed with anin_have_frame_driverflag: the loop'sinput_drivesets it, and a manualInput.pollunder 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 eachInput.pollstill 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.llwin_poll) used to hard-code Escape (keycode 53) and the character 'q' as quit — storingW_running = 0so 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#50held-key set (ev_keyvalnow maps Escape→27, not 'q'). A windowed game now owns Esc/pause and shuts down viaquit()or the window close button (which still ends the run). The headless test driver (rt_pollincore.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
enumvariant may now carry a payload (enum Tile { Empty, Wall, Door(int), Portal(int, int) }), making it a sum type. Variants construct by name andmatchdestructures 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 returnsok(payload)orerr(message), andtry EXPR else { … }recovers a value with the failure message bound toerror. A plain branch on the tag: no exceptions, no stack unwinding.is_ok/is_errclassify without unwrapping. - feat:
option(#53) —some(v)/none(), a maybe-a-value with no magic-1sentinel, read withis_some/is_none/unwrap_or. - feat:
panic(msg)+assert(cond, msg)(#8) — a clear, locatedfile: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+Rect2D 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+Decimalexact numbers (#52) — arbitrary-precision integers and exact base-10 fixed-point for game economies, so an idle counter never overflows and0.10 + 0.20is exactly0.30. Nof32/f64; deterministic. - feat:
Huge+Angle+Percent(#55) — a display-scale idle/incremental big number (1.23e45), an auto-wrapping radian angle over deterministicMath.*trig, and a[0,1]-clamped fraction for health, volume and interpolationt. - feat:
Dict+Setcontainers (#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
Valuenode,Reflect.serialize/applyto walk an entity's whole component set to and from it bit-exactly, andJson.encode/parsefor 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
handlerwired:SpriteAnimadvances sprite-sheet frames andMotionadvances 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.playfor named spritesheet clips,Anim.on_frame/firedframe events,Motion.toone-call tweens, and fluent engine-advancedTweenhandles (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:ambienttinting, additive radialpointlights 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 anOccluder, and oneAmbientsets the night tint; the engine runs the whole light pass at the end of the Render phase with noLight.*calls wired by hand. - feat: Lighting render-quality tiers (#49) — cone/flashlight
spotlights, afalloffexponent,softpenumbra shadows, colourgelcookies, normal-mapped surfaces (N·L) and atime_of_dayday/night ramp, all on the same deterministic accumulation core and consumable via optionalLight2Dfields.
Input
- feat: Action maps + record/replay (#7) — gameplay reads named, rebindable actions instead of physical keys, and the single per-frame
Input.pollmakes deterministic replay fall out for free:Input.recordcaptures the tape andInput.replayfeeds 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 withJson.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 withPromise.all/race. The advancedSync.*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 (noentryto write), withexpect/expect_eq/expect_nearassertions that report every failure and exit non-zero.expect_nearcarries the tolerance fixed-point game math needs. - feat: Line coverage (#45) — compile with
--coverageand the compiler instruments each statement with a per-line hit counter dumped at exit;bin/x test --coverageaggregates the dumps into a per-file report naming the unreached lines. Flag-gated and additive — an ordinary build stays byte-identical.
Fixes
- fix:
constof a non-int type is no longer miscompiled. Aconstreference lowered to its initializer's raw integer bits typed asint, soconst X: fixed = 10.0computed as the raw Q16.16 value655360instead of10.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 anintliteral, 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) andludic-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-cfreeproves the compiler rebuilds itself byte-for-byte. - Versioning and releases — SemVer with
ludicc --version, a changeset-drivenCHANGELOG.md, andx releaseto 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.