# 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/`); 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 [@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//… the package tree at a version (no .git) ~/.ludic/store/cache// a git clone cache used during resolution ``` Each project gets a lightweight view — `ludic_modules/` 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. ## Package-declarable namespaces and engine systems (issue #62) A package can register two things that used to be compiler-hardcoded — a `Foo.*` namespace and an engine-owned system — with **no compiler edit**, via two keyword-free annotations. This is what lets gameplay-controller libraries ship as ordinary packages instead of living in the compiler's stdlib. - `@Namespace(Name)` on a function opens a `Name.method(…)` namespace that dispatches to the bare `name_method(…)` (the same generic path the built-in namespaces use, applied only after them so it never shadows a core one): ``` @Namespace(Coach) function coach_bonus() -> int { return 99 } # a consumer then writes Coach.bonus() ``` - `@EngineSystem(Component, Phase)` registers an engine-owned system: the frame loop calls it every `Phase` (after the game's own handlers) whenever the named `Component` is present — the package-declarable form of the built-in `SpriteAnim`/`Motion`/`Light2D` systems. It reads and writes components by name through the reflection ABI, so an unused registration is byte-identical: ``` @EngineSystem(Score, Update) function esys_score() -> void { … } ``` The core stdlib namespaces and engine systems keep their own optimized codegen; packages flow through the generic registry alongside them. (For a *prebuilt* binary module, the runtime counterpart of `@EngineSystem` is `@System(Phase)` — see below.) ## 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 a `module_init` function; the host owns storage, the module reads/writes by name with `world_get`/`world_set`/`world_has`/`world_attach_dyn`. - **Systems** — a function marked `@System(Phase)` is registered with `ludic_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//lib.dylib # add `kind prebuilt` and `targets ""` 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.