ludic/docs/PACKAGES.md
Orkuncakilkaya 6c6dfae235
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 20s
ci / build-and-test (push) Successful in 1m41s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 27s
docs(pkg): document @Namespace / @EngineSystem / @System + package hooks (#62)
Per-annotation pages for the three package-registration annotations (with
inventory entries), a "Package-declarable namespaces and engine systems" section
in docs/PACKAGES.md, and a changeset. All doc gates green (check-docs 675
fences, docs-check 726 symbols).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 12:31:47 +03:00

9 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.

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/<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.