The package-manager half of prebuilt binary packages, on top of the compiler
foundation (dynamic system registration + --emit-module).
- x build-lib [module.ludic]: compile a package's module to a per-target native
dylib under lib/<target>/, with an @rpath install name so a consumer resolves
it from the content-addressed store.
- x link-flags: print the clang flags (the dylib, an rpath to its store dir,
-export_dynamic) so any build system links a project's prebuilt module dylibs;
x app splices them automatically for in-repo builds.
- kind prebuilt is resolved + linked like any dependency; a missing build target
stays a hard error.
Proven hermetically (macOS-gated, since dylibs are native): a module exporting a
component + an @System(Update) + a function is built with x build-lib, fetched
as a prebuilt dep, and linked into a consumer game that never saw its source —
the module's system mutates the shared world and its function is callable
("3 42"). Package suite 17/0; full suite 87/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.6 KiB
Ludic packages
Ludic has a package manager built into the task runner (bin/x). It fetches,
resolves, stores and links third-party packages with no new infrastructure to
run — it drives plain git and rides on the Forgejo host and its release tags.
This is the v1 implementation of the direction decided in issue #63.
The four decisions
| Axis | Ludic's choice |
|---|---|
| Where source comes from | URL-as-identity, no registry. The import path is the git location — git.workshopsoft.io/user/pkg. A git tag vX.Y.Z publishes a version. No account, no publish step, no index to run. |
| Version resolution | Minimum Version Selection (MVS), Go-style. A require is a minimum; the resolver picks, per module, the greatest of every required minimum, then keeps the reachable closure at those versions. Deterministic, no SAT solver. |
| Where dependencies live | A content-addressed global store + per-project links (pnpm-style). One immutable store keyed by a file-content hash (~/.ludic/store/<sha256>); each project gets a light linked view under ludic_modules/ instead of a full copy. |
| Manifest / lock | package.ludic (declared deps + provided namespaces + kind + targets) and package.lock.ludic (resolved versions + content hashes). |
Commands
x add <module>[@version] add a dependency to package.ludic, then resolve + fetch + link
x get resolve every dependency in package.ludic, link them, write the lock
x update [module] bump a dependency (or all) to its latest published version, then relock
x verify check every locked package against the store by content hash
x vendor copy the resolved packages into ./vendor for hermetic/offline builds
x add with no @version picks the latest published tag and records it as the
minimum. All the install commands print the resolved build list and write
package.lock.ludic.
The manifest — package.ludic
A line-oriented manifest. # starts a comment; strings are double-quoted.
package "git.workshopsoft.io/orkun/greeter" # this package's import path
version "1.2.0" # the version this checkout publishes
kind source # source | prebuilt
provides "Greet" # the Foo.* namespace(s) it registers (repeatable)
require "git.workshopsoft.io/orkun/util" "1.0.0" # a dependency and its minimum version
# a prebuilt lib also declares the targets it ships:
# kind prebuilt
# targets "native-arm64" "wasm32"
A consumer project's manifest is the same file, usually with only require
lines (the package/version fields describe a publishable package and are
optional for a leaf application).
The lockfile — package.lock.ludic
Generated by x get; do not edit by hand. One line per resolved module, pinning
its selected version, content hash, kind and provided namespaces:
# package.lock.ludic — generated by `x get`. Do not edit by hand.
lock 1
module "git.workshopsoft.io/orkun/greeter" version "1.2.0" hash "sha256:…" kind "source" provides "Greet"
module "git.workshopsoft.io/orkun/util" version "1.0.0" hash "sha256:…" kind "source" provides "Util"
x verify rehashes each store entry and confirms the project links to it, so a
tampered or missing dependency is caught before it reaches a build.
The store and the project view
Fetched packages live once in a global, immutable, content-addressed store:
~/.ludic/store/<sha256>/… the package tree at a version (no .git)
~/.ludic/store/cache/<module>/ a git clone cache used during resolution
Each project gets a lightweight view — ludic_modules/<import-path> is a symlink
into the store — so many projects share one copy and nothing is duplicated
per-project. Override the store location with $LUDIC_STORE.
Consuming a package — namespace registration
A source package ships plain Ludic. The consumer imports the package files by their import path:
program App {
import "git.workshopsoft.io/orkun/greeter/greet.ludic"
entry { print(greet_hello()) }
}
The compiler resolves an import first relative to the importing file, then — for
a non-absolute path that is not found — under the package module root
($LUDIC_MODULES, default ludic_modules/). So a fetched package's code is
spliced into the build and its namespace becomes available exactly the way the
built-in stdlib namespaces (Regex.*, Grid.*, …) are. Because Ludic compiles
ahead-of-time, a source package is compiled into the consumer's binary — no
ABI seam, and the whole-program guarantees (determinism, replay, world_save)
still hold.
Two packages may not register the same Foo.* namespace — a collision is a hard
error naming both modules.
Prebuilt binary packages (issue #64)
A kind prebuilt package ships a compiled artifact (a native dylib per
target it lists in targets) instead of source. A consumer uses its exported
functions, systems and components without ever seeing the source. This is
the escape hatch for closed-source or other-language code; source packages stay
the default because they keep cross-compilation (including wasm) and the
compile-time ECS first-class. Prebuilt packages are native-only and ride the
stable reflection C-ABI — second-class ECS (dynamic, by-name components; one
indirect call per registered system), not part of the deterministic/replay core.
How a binary module works. The module is compiled with --emit-module: no
main, no world table (the consumer owns the single world). It carries a
load-time constructor that, when the dylib loads, registers its pieces against
the host through the C-ABI:
- Components —
world_register_prop(name, nfields)in amodule_initfunction; the host owns storage, the module reads/writes by name withworld_get/world_set/world_has/world_attach_dyn. - Systems — a function marked
@System(Phase)is registered withludic_register_system; the host's frame loop calls it every frame in that phase, after its own handlers. Phases:Input,FixedUpdate,Update,LateUpdate,Render,Start,OnQuit. - Functions — plain functions become dylib symbols; a consumer binds them
with
extern function name(...) -> T = "fn_name".
A module registers its ludic_* calls as undefined and binds them back to the
host image at load (-undefined dynamic_lookup); the host exports its ABI
(-export_dynamic). Every Ludic game is a capable host — the reflection ABI is
always emitted (unused parts dead-strip).
Publishing. In the package repo:
x build-lib module.ludic # -> lib/<target>/lib<name>.dylib
# add `kind prebuilt` and `targets "<target>"` to package.ludic, commit lib/, git tag
Consuming. In the game project:
x add git.host/user/module # kind prebuilt is resolved + the dylib linked into the view
x get # links the artifact for the build target (hard error if the target is missing)
then link the module dylibs into the game build. x link-flags prints the exact
clang flags (the dylib, an rpath to the store, -export_dynamic) for any build
system to splice into its link step:
clang -O2 game.ll $(x link-flags) -o game
(x app links them automatically when building in-repo.) If the package does
not ship the build target, x get fails — build from source instead where the
package offers it.
Offline / hermetic builds
x vendor copies the resolved packages out of the store into ./vendor. Build
against the copy with LUDIC_MODULES=vendor, so the build needs neither the
network nor the global store.