- JavaScript 40%
- Python 29.2%
- Go 15.4%
- HTML 5.2%
- Shell 3.1%
- Other 7.1%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| benchmarks | ||
| cluster | ||
| config | ||
| gateway | ||
| nix | ||
| scripts | ||
| site | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .envrc | ||
| .gitattributes | ||
| .gitignore | ||
| compose.authentik.yaml | ||
| compose.yaml | ||
| flake.lock | ||
| flake.nix | ||
| Makefile | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Luanti browser lab
A static browser client, one Go gateway, and a dedicated VoxeLibre server.
The gateway embeds mt-multiserver-proxy, adds the WASM client's WebSocket transport, and adapts Authentik identities to Luanti SRP authentication. Node is used only for frontend tests. The website is served by Caddy.
Local development
Use direnv allow or nix develop. No editor devcontainer is needed.
make build # Compile the WASM client and produce dist/
make up # Start static hosting, gateway, and game through Compose
make refresh # Repackage web/settings/media changes without compiling the engine
make test # Frontend, media, and Go gateway tests
Open http://localhost:8080. Local mode accepts a Luanti name/password before launch, registering a new name on first join. Passwords are not stored by the launch page. make down stops the services; world and gateway state remain in .runtime/.
All service/build/CI images use Nix snapshotter. make uses nerdctl compose, requiring containerd configured with nix-snapshotter; ordinary Docker/Podman cannot run these images. The flake currently targets x86_64 Linux, matching the upstream SDK and cluster. The build image supplies the loader needed by downloaded Emsdk binaries. The Nix shell supplies native editor tools.
Faster loads
scripts/deploy-web.py writes zstd and gzip sidecars (.zst, .gz) for the Wasm
engine and other text assets (scripts/precompress.py). Caddy's file_server
serves whichever sidecar matches the browser's Accept-Encoding, so the engine
Wasm (about 14 MiB) transfers at roughly a quarter of its size on first load,
without spending CPU on per-request compression. Only the content-addressed
release directory and media packs receive long-lived immutable caching; the HTML
shell, service worker, and manifest still revalidate.
Whole-engine optimizer flags are opt-in. In Forgejo, open Actions → Test and
build → Run workflow and enable aggressive_optimize to build with
-O3 -flto. It raises build time and peak memory, so the default push and PR
builds never use it. The default level is -O2 (wasm_opt_level can raise a
manual run to -O3); the scalar SIMD-fallback engine is also manual-only
(simd_fallback), halving the default compile. Locally, set LUANTI_AGGRESSIVE_OPT=1 before make build
(or export it for the Compose builder service). Switch features on an
incremental tree with RECONFIGURE=true INCREMENTAL=true.
Pushes that touch no engine inputs (build/patch scripts, pinned sources,
injected web/ modules, runtime configs) skip the ~45-minute compile and
reuse the last green main artifact; Nix images build in parallel since they
never needed dist/.
Authentik
For local SSO, set AUTHENTIK_OUTPOST=https://auth.maio-tech.com in .env and start with:
make images
nerdctl --snapshotter nix compose -f compose.yaml -f compose.authentik.yaml up -d
The launch page remains public, with a Sign in with Authentik button. Caddy validates /api/session and every /proxy connection through the outpost, clears browser-supplied identity headers, and passes verified UID/name headers to the private gateway. The gateway assigns a readable player name from the verified Authentik username, persists its ownership by UID, then issues a random five-minute SRP login credential. Credentials exist only in gateway memory and the browser's launch memory; the Authentik password never reaches Luanti. Minting a new credential replaces a previous unconsumed one for that player. Expiry governs new authentication; it does not end an already connected game session.
Back up identity.key and identity-names.json alongside the world. Names retain ASCII letters, digits, underscores and hyphens, up to the proxy’s 20-character limit; collisions receive a stable suffix. The first assigned name survives Authentik username changes and gateway restarts. Existing ak_ player data is not automatically migrated to the new readable names. SSO accounts remain separate from local gateway accounts.
Use a forward-auth single application provider for each app origin and attach it to the embedded outpost. Providers have been configured for http://localhost:8080 and https://luanti.maio-tech.com. Plain game UDP and the gateway HTTP port remain private. Cluster NetworkPolicies enforce gateway access from Caddy and game access from the gateway.
Deployment and CI
Deployment is through Flux, not kubectl apply. cluster/ owns the application manifests. The Servers repository registers this repository as a Flux GitRepository and Kustomization. Its existing HTTPS Gateway routes luanti.maio-tech.com to Caddy. Persistent Longhorn volumes hold the world and gateway state. The public deployment has its own world, separate from .runtime/world.
The Forgejo workflow uses the luanti Kubernetes runner label, backed by image-ci. It runs checks, compiles the pinned browser engine, uploads dist/, and builds immutable snapshotter images. On main, ATTIC_TOKEN enables publishing image closures to the existing main Attic cache and committing updated image references to this repository for Flux. With no token, it builds artifacts but skips deployment updates. The checkout token needs repository write permission for that update. Pull requests never publish or update deployment manifests.
To produce deployment images manually:
nix build .#image-web .#image-proxy .#image-game \
--override-input web-dist path:./dist --no-link --print-out-paths > images.paths
attic push main --stdin < images.paths
python3 scripts/update-images.py images.paths
# Commit/push cluster changes; Flux reconciles them.
The web-dist override imports the compiled static artifact into the web image's Nix closure. Default web-dist points to source files and deliberately fails a web-image build until overridden with a compiled dist/.
Repo layout
web/: launch page, account UI, FPS overlay, adaptive distance/resolution controller.config/: graphics defaults, server settings, local and Authentik Caddy configuration.gateway/: Go multiserver wrapper, WebSocket adapter, short-lived SSO auth backend, tests.nix/,flake.nix,.envrc: pinned packages, snapshotter images, native development shell.compose*.yaml: local services and optional Authentik configuration.cluster/: Flux-managed Kubernetes application and immutable image references..forgejo/workflows/: tests, browser build, image publication, deployment updates.scripts/build.sh: pinned upstream build;fetch-game.sh: verified game download;patch-engine.py: FPS/live range and credential-log patch;patch-build.py: SIMD/embed/aggressive flag and heap-memory patches;gateway/patches/content-cache.patch: proxy cache and backend error fixes;deploy-web.py: static packaging;precompress.py: zstd/gzip sidecars for Caddy;bundle-media.py: HTTP media preload;update-images.py: Flux image/config update.upstream/,.runtime/,dist/: downloaded sources/SDK, local state, generated static output. Excluded from Git.
Client behavior
The browser build is pinned to paradust7/luanti-wasm commit 3d6796615a8f42ddee7b169589025d167d3181d6 (Luanti 5.18-dev). Server: 5.17.0. VoxeLibre: 0.92.3. The multiserver proxy is pinned in gateway/go.mod; upstream supports stable 5.17, so compatibility with this development client needs retesting on every engine update.
Media preloads over HTTP into the browser's persistent OPFS SHA-1 cache before Launch. A content-addressed 60 MiB pack is reused across engine rebuilds. Add external mod roots to the media collector when introducing mods. Server world generation and shader compilation still occur after launch. The proxy loads and validates its persistent media cache into memory before accepting clients. The /api/ready probe keeps the gateway out of service while that startup load runs. Backend definitions then use memory cache lookups, and unchanged media files are not rewritten on every login. This avoids persistent-volume I/O blocking packet reception long enough to time out. Backend content rejection now stops this stage immediately instead of sending partial definitions and later reporting “media pool unknown to client”; inspect [content voxelibre] proxy logs for the backend reason.
Automatic graphics on both desktop and mobile starts at 64 nodes / 30 FPS with post-processing and low shadows enabled. It grows distance to 160 nodes, raises the cap to 60 FPS, then reduces the 3D render-target pixel budget if needed to reach 60 before expanding distance further. Resolution recovers before distance expands. Four sustained low-FPS samples lower quality; twenty high-FPS samples recover it, after a 14-sample warmup and with a 15-second cooldown. Hidden tabs do not drive changes. Manual mode cancels queued FPS and distance updates; changing either inside Luanti also switches Automatic off. Effects and shadow-map size remain fixed for the session because they require an engine restart. FPS and inverse-FPS average frame time appear top-right.
Cached media is read directly on the game worker through JSPI, bypassing the WasmFS open/stat/read/close proxy calls. The downloader supplies server media in consumption order; four files at a time are read ahead, with a 16 MiB per-file limit. Consumed buffers are released, and unavailable storage, cache misses, non-media paths, and larger files use the existing filesystem path. Server media SHA-1 checks remain in place. The general browser storage reader also buffers each immutable OPFS file snapshot up to 16 MiB once per open file using weak references, avoiding repeated async reads of small chunks. These patches are applied by scripts/patch-engine.py before linking the client.
Use recent Chromium on desktop or Android over localhost or HTTPS, with WebAssembly JSPI, threads, OffscreenCanvas, and WebGL2. The launch page checks engine compatibility; installing the app does not bypass those requirements. Caddy supplies the required isolation headers. WebTransport is feasible through a new QUIC endpoint and modified emsocket shim, but this deployment uses WebSockets; browsers cannot connect directly to the proxy's raw UDP listener.
Mobile app experience
Install VoxeLibre from the launch page's Install VoxeLibre button when the browser offers installation, or use the browser's Install app / Add to Home Screen menu. The installed PWA requests a fullscreen window with an app icon and matching theme colors. The launcher keeps the device's normal orientation; tapping Launch requests fullscreen and locks landscape for gameplay. The in-game Fullscreen button restores it if released. Browsers that do not support these requests keep their normal display behavior. Existing installations may take time to refresh their manifest; the launch request works independently. Installation requires HTTPS (localhost works for development). On iOS, use Safari's Share → Add to Home Screen; running the game still requires the browser features above and is not guaranteed on Safari.
The launch form fits portrait and landscape screens, respects safe areas, and uses touch-sized inputs. Controls → Automatic selects native Luanti touch controls when the primary pointer is touch; choose Touch or Keyboard & mouse to override this before launch. Touch mode enables the engine's joystick, touch menus, and tap interaction, with GUI scale 0.8 and HUD scale 0.7. The browser performance overlay is compact on touch devices in either orientation. Landscape gives the controls more space. The canvas resizes with orientation and viewport changes.
The service worker provides a connection message when opening the installed app offline. Gameplay and sign-in require a live server connection. Authentication, engine assets, and the existing OPFS media preload remain network-managed; the service worker caches only its small offline page and installation assets. Updates activate after existing app windows close. make refresh packages the manifest, icons, and worker, including a content-based offline cache version. Regenerate the app icons with python3 scripts/generate-icons.py.
To test a gateway build against a real backend, compile gateway.test in a build pod or on a server, then run LUANTI_TEST_BACKEND=game:30000 ./gateway.test -test.run TestBackendLogin -test.v from an isolated executable directory. Its cache/ may contain a copy of the deployed media cache. The test uses a separate loopback UDP port and confirms a readable SSO name joins with complete definitions. PROXY_PORT can select a separate internal loopback port when testing a gateway alongside a running instance; the default remains 40000.
Mobile graphics start with a one-time preset: 30 FPS, post-processing enabled, 512px shadows, and a 50-node shadow distance. Later launches retain the native Luanti settings. Automatic graphics adjusts view distance down to 64 nodes before reducing resolution; recovery restores resolution before increasing distance. The in-game Auto graphics switch disables adaptation and cancels queued view-distance changes. Selecting Manual · game settings also preserves the native view distance on launch. Changing view distance in Luanti while Automatic is active switches the browser to Manual. Graphics settings that require an engine restart still require relaunching the game.
Touch controls default to a crosshair with separate dig/place buttons. Use Luanti's touch-layout editor and Interaction style setting to customize them; saved layouts and UI scaling are retained.
The launcher Use mobile graphics preset button explicitly reapplies the mobile profile without silently replacing existing saved settings. The profile also adopts Android's performance tradeoffs, simple leaves, 8-bit post-process targets and disabled debanding; it keeps post-processing and low shadows enabled. Mobile font defaults are 14px. Native Android uses ARM64 and direct OpenGL ES; the browser uses WebAssembly and WebGL. Matching visible effects does not make these execution paths equivalent. Safari 27 adds JSPI, but this build has not been validated on an iOS device; older Safari needs another asynchronous build strategy. See https://webkit.org/blog/18325/webkit-features-for-safari-27-0/.
Browser startup now stores server media as one indexed OPFS binary instead of extracting thousands of files. Only completed downloads publish a ready index; interrupted installs retry, and unavailable storage falls back to the archive installer. The engine reads bundles up to 96 MiB into one temporary buffer, shares slices without extra JavaScript copies, and releases the buffer after the initial media queue is consumed. Larger bundles and failed whole-pack reads use bounded slice read-ahead. Each server-announced hash is still verified. The first visit downloads the binary once (about 74 MiB), and later visits reuse it. Base packs are versioned by their content, avoiding re-extraction after engine-only changes.
Touch defaults now enable auto-jump, expose inventory and chat outside the overflow menu, and leave room around a narrower hotbar for movement buttons. The updated touch preset is applied once, then saved custom layouts remain in control. The launcher preset button can reapply it explicitly.
The browser also caches the built-in texture directory listing for the session, so downloaded PNGs don't trigger repeated missing-file OPFS stats for local replacements. Custom texture-pack and nested paths retain their normal lookup, and changing texture packs clears the listing. Media-loading progress redraws at most ten times per second, and Automatic disables animated loading clouds.
Browser startup allocates a 512 MiB shared WASM heap that can grow as needed, rather than allocating 1.875 GiB immediately. The preloaded pthread pool is 18 workers, with room for the maximum configured mesh workers. Allocation capacity is not a measurement of physical memory use.
Native text-field focus opens a browser input and the mobile keyboard. Text, autocorrect and composition feed SDL text events; SDL skips the DOM input's key events to avoid duplicate typing, and canvas resizing preserves input focus. The input stays above the keyboard and can be tapped to reopen it. Touch-generated compatibility mouse events on the canvas are suppressed, while real mouse input continues normally.
Dynamic resolution scales the 3D and post-processing textures while keeping the canvas, HUD, menus, and touch controls at their base resolution. Fixed-size shadow and exposure targets stay fixed. With performance tradeoffs enabled and volumetric lighting disabled, bloom extraction renders directly into its first half-resolution target and skips the first downsample pass; its remaining blur levels and shadows remain enabled. Mobile keeps the Auto switch and FPS visible; the adjacent options button contains the distance selector and fullscreen button.
Builds default to two compiler and Binaryen workers (BUILD_JOBS overrides both)
to limit peak memory. Release engines compile at -O3 (LUANTI_WASM_OPT_LEVEL
overrides with -O1/-O2/-O3/-Os/-Oz); shipped modules are stripped of
debug info unless LUANTI_WASM_STRIP=0, and Caddy serves compressible assets
with zstd/gzip plus immutable caching for versioned engine and media bytes.
dist/ retains the deployed plus previous engine/media generations for
in-flight clients; older ones are pruned on each packaging (www/ keeps
everything as a build cache).
Loading phase transitions are available in
luantiStartup.phases for timing real device launches.