-
v0.4.0
Pre-releaseSome checks failedreleased this
2026-09-05 00:52:18 +02:00 | 996 commits to main since this releasev0.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 with
ld: open() failed, errno=2 for 'bin/ludicc'. Every copy now begins with
mkdir -p bin, which is what CI had been doing all along. -
Unary minus keeps its operand type:
-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 through
tr '\n' ' ', which flattened every multi-line changeset into one paragraph —
nested bullets came out as inline" - "runs and a release read as a single
unbroken wall of text. A section is now grouped by change type (Features,
Fixes, Performance, …) with one bullet per changeset and continuation
lines indented to stay inside it.x release --dry-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 from
CHANGELOG.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 underprefers-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 whichbin/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 flagludicc
already passes for its own link step: the emitted IR names no target triple, so
clang substitutes the host's and says so — four times 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
newreleaseworkflow 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 builds
dist/(a source tarball from the tag, this host's toolchain, and a
SHA256SUMScovering 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.
Downloads
-
Source code (ZIP)
1 download
-
Source code (TAR.GZ)
1 download
-
SHA256SUMS
2 downloads · 187 B
-
ludic-0.4.0-linux-x86_64.tar.gz
1 download · 1.5 MiB
-
ludic-0.4.0-src.tar.gz
0 downloads · 1.8 MiB
- Prefabs.