38 KiB
Changelog
All notable changes to the Ludic toolchain, newest first. Generated from the changesets under changes/ by x release; do not edit released sections by hand.
v0.3.0 — 2026-09-02
- feat: 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 runtimert_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 tilemapesys_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). - feat: 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. - feat: 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). - feat: 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). - feat: 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). - feat: 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. - feat: 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. - feat: 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. - feat: 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). - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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. - feat: 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). - fix: 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. - fix: 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). - fix: 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
- 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.
- feat: Editor tooling —
ludic-fmt(formatter) andludic-lsp(language server), plus VS Code and JetBrains integrations, all built by the toolchain. - feat: 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.
- feat: 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.
- feat: 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. - feat: 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.