ludic/docs/SHIPPING.md
Orkuncakilkaya be74b4de6f feat(bundle): ship a game as a macOS .app, with a splash it controls
`ludic build` produces a program. Double-clicked it opens a Terminal window, it
wears the generic executable icon, it calls itself whatever the file is called,
and it carries none of its assets. `ludic bundle` produces an application.

Everything it needs is in package.ludic, so the command takes no arguments: an
Info.plist and PkgInfo from `app` lines, an .icns built by sips and iconutil at
all ten sizes macOS asks for from a single source PNG, the asset pack in
Contents/Resources, and an ad-hoc signature - which is not optional on Apple
silicon, where an unsigned binary is killed rather than warned about. The bundle
identifier falls back to the package path reversed, so a project that never
thinks about it still gets a defensible one instead of two apps sharing a key
Launch Services hangs the Dock, saved state and permissions off.

A bundled game is moved to ~/Library/Application Support/<name> before main,
because Finder starts a .app with its working directory at "/" where no save
could ever be written. Reads come out of the pack, writes land somewhere real
and per-user, and the game's save code needs no change and no platform
knowledge.

The splash is the other half of looking like an application. A game that loads
165 MB spends a visible moment doing it with nothing on screen, which from the
outside is indistinguishable from a launch that failed. splash_show puts a
borderless window up from the same constructor that mounts the pack - before
main, so it appears while the process is still starting rather than after the
slow part it exists to cover - and reads the artwork out of the pack like any
other asset. It turns the run loop enough times to be mapped and composited
there and then; once composited the backing store survives a busy main thread,
so it stays up for the whole load.

Nothing hides it automatically. Only the game knows when its first real frame is
ready, and a splash that vanishes before that leaves the same black gap it was
covering, so the game calls App.splash_hide(). Headless there is no splash and
the call lowers to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:57:27 +03:00

6.5 KiB

Shipping a Ludic game

ludic build gives you a program. ludic bundle gives you an application.

The difference matters more than it sounds. A built binary opens its assets by a path relative to the working directory, so it runs from the project root and nowhere else; double-clicked it opens a Terminal window; it wears the generic executable icon and calls itself whatever the file is called. None of that is something you can hand to a player.

ludic pack        # every asset into one file
ludic bundle      # ...and that, the binary, an icon and the metadata, as a .app

What goes in the manifest

Everything the bundler needs lives in package.ludic, so the command takes no arguments:

package "git.workshopsoft.io/workshopsoft/maroon-lake"
version "0.1.0"

app  name       "Maroon Lake"
app  id         "io.workshopsoft.maroon-lake"
app  icon       "assets/app/icon.png"
app  splash     "assets/app/splash.png"
app  splash_bg  "#0d1b2a"
app  category   "public.app-category.adventure-games"
app  copyright  "(c) 2026 Workshopsoft"
app  min_macos  "11.0"

pack "assets"
key what it does default
app name the name under the icon, spaces and all the package's name
app id the bundle identifier derived from the package path
app version CFBundleVersion the manifest's version
app icon a source PNG, ideally 1024x1024 no icon
app splash shown from process start until the game says otherwise no splash
app splash_bg #RRGGBB painted behind the splash black
app category LSApplicationCategoryType omitted
app copyright NSHumanReadableCopyright omitted
app min_macos LSMinimumSystemVersion 11.0
app sign a codesigning identity ad-hoc (-)
app out where to write the bundle build/<name>.app
pack an asset root, repeatable assets/ if it exists

Only app name is worth setting deliberately. The identifier is derived from the package path when you omit it - git.workshopsoft.io/workshopsoft/maroon-lake becomes io.workshopsoft.maroon-lake - but it is worth pinning, because Launch Services keys the Dock, saved window state and permissions off it, and two apps sharing one identifier is a class of bug that looks like haunting.

The asset pack

ludic pack writes a .lpak: a header, an entry table sorted by name, a name heap and the blobs. Entries are stored, not compressed - PNG, JPEG and glTF binary arrive compressed already, and a decompressor on the load path would spend CPU to make the file no smaller.

Nothing about how the game is written changes. This line:

let model = gltf_load("assets/kit/hiker", "hiker.gltf", "hiker")

reads a file during development and a run of bytes inside Maroon Lake.app/Contents/Resources/game.lpak once shipped, and cannot tell which. That works because the pack is spliced in at file_open, the one place every asset in a Ludic program comes through - gltf_load, tex_load, Audio.load, Fs.read_text and the renderer's own shader loads all bottom out there. Fs.exists and Fs.size consult the packs too, so a game that guards a load with Fs.exists keeps finding its assets once they are packed.

The pack is mmap'd rather than read, so 165 MB of terrain costs one syscall at startup and pages in only what the game touches.

ludic pack --list build/maroon-lake.lpak     # what is in it
ludic pack --verify build/maroon-lake.lpak   # re-hash every entry

More than one pack

Contents/Resources/packs.index is a plain list, and the order is load-bearing:

pack base.lpak
pack patch.lpak

Packs mount in the order listed and a later one shadows an earlier one, so a patch or an add-on replaces individual files without rewriting the base pack. An override is always a pack, never a loose file: the pack is searched before the filesystem, so a stray or corrupt file cannot shadow a shipped asset.

Where a bundled game saves

A .app launched from Finder starts with its working directory at /, where nothing is writable, so packs.index also carries:

home Maroon Lake

and the runtime moves the process to ~/Library/Application Support/Maroon Lake before main. Reads come out of the pack; writes land somewhere real and per-user. A game's save code needs no change and no platform knowledge.

The splash

A game that loads 165 MB spends a noticeable moment doing it, and until it is done there is nothing on screen - the window is not open yet, because opening it is part of what the game does once it has something to draw. From the outside that is indistinguishable from a launch that failed.

The splash is raised from a constructor that runs before main, so it appears while the process is still starting rather than after the expensive part, which is the only ordering that helps. It is a borderless window holding the game's own artwork, read out of the pack like any other asset.

Nothing hides it automatically. Only the game knows when its first real frame is ready, so the game says so:

App.splash_hide()

During development

None of this is in the way. Without a packs.index beside the executable - which is every ludic run - nothing mounts, no directory change happens, no splash appears, and every open goes to the filesystem exactly as before. Packing is a shipping step, and it is invisible until you ship.

Signing and distribution

ludic bundle ad-hoc signs the result. On Apple silicon that is not optional the way it was on Intel: an unsigned binary is killed outright rather than merely warned about, so without a signature the app will not run even on the machine that built it.

An ad-hoc signature is not enough to get the app past Gatekeeper on someone else's Mac. That needs a Developer ID certificate and notarisation, which are Apple's, not Ludic's:

app sign "Developer ID Application: Your Name (TEAMID)"
ludic bundle
xcrun notarytool submit "build/Maroon Lake.app" --keychain-profile NOTARY --wait
xcrun stapler staple "build/Maroon Lake.app"

The bundle it makes

Maroon Lake.app/
  Contents/
    Info.plist                 the metadata Finder, the Dock and Launch Services read
    PkgInfo                    the eight bytes that predate Info.plist and are still read
    MacOS/Maroon Lake          the game
    Resources/
      AppIcon.icns             every size macOS asks for, from one source PNG
      game.lpak                every asset the game opens
      packs.index              what to mount, where to save, what to show at boot