De-abbreviate the two bare fixed-point conversion builtins:
flr(f) -> int -> floor(f) -> int (fixed -> int, flooring)
fx(i) -> fixed -> fixed(i) -> fixed (int -> fixed; mirrors how the
stringify builtin is `string`)
Updates the compiler dispatch, all call sites, the grammars/LSP/JetBrains
tokens, and the docs (fn-flr -> fn-floor, fn-fx -> fn-fixed). Reseeded;
C-free fixpoint holds; all suites green (45/24/29); site + check.py OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1560 lines
62 KiB
Markdown
1560 lines
62 KiB
Markdown
# Luanti → Ludic: Full Gap Analysis & Implementation Roadmap
|
||
|
||
**Question asked:** *what does Ludic miss in order to implement Luanti (formerly
|
||
Minetest) — the game/engine?*
|
||
|
||
**Short answer:** Ludic can already express Luanti's **game logic**. It cannot yet
|
||
express Luanti's **engine**. The gap is not one feature; it is roughly six
|
||
layers, and two of them (aggregate data types, and a platform layer with
|
||
threads + sockets + a GPU) are load-bearing for everything above them.
|
||
|
||
This document is written against evidence, not memory: `luanti-org/luanti` at
|
||
`main` was cloned and read, and every claim about Ludic below was verified
|
||
against `compiler/ludicc.c` / `compiler/native.c` or by compiling a probe
|
||
program with `bin/ludicc`.
|
||
|
||
---
|
||
|
||
## 0. TL;DR
|
||
|
||
| | Luanti | Ludic today |
|
||
|---|---|---|
|
||
| Size | **362,258** lines (C++ / Lua / GLSL) | 2,508 lines of compiler + 3,692 of runtime |
|
||
| World | infinite 3D voxel, ±31,007 nodes/axis | 96×64 char tilemap, 2D |
|
||
| Entities | unbounded active objects, chunked | **fixed 1,024** (`LUDIC_MAX_ENT`) |
|
||
| Rendering | GPU, 20 shader programs, Irrlicht fork (87k LOC) | 320×240 software framebuffer |
|
||
| Extensibility | Lua sandbox, hot-loaded mods, 321 `core.*` calls | AOT compile only |
|
||
| Concurrency | emerge threads, mesh threads, net threads, async Lua | **none** |
|
||
| Networking | custom reliable UDP, 90 packet types | **none** |
|
||
| Numbers | `f32` / `f64`, `v3f`, matrices | `int`, Q16.16 `fixed` (range **±32,768**) |
|
||
| Aggregates | vectors, maps, strings, structs | **none** — raw `ptr` + `peek/poke` |
|
||
|
||
**The single most surprising finding:** Ludic's `fixed` type **cannot represent a
|
||
Luanti world coordinate.** Q16.16 saturates at ±32,768; Luanti's map limit is
|
||
±31,007 *nodes*, and positions are carried in *BS units* where `BS = 10.0`
|
||
(`src/constants.h`) — i.e. ±310,070. Worse, any squared distance
|
||
(`31007² ≈ 9.6e8`) overflows Q16.16 by five orders of magnitude. Every collision,
|
||
raycast and physics computation in Luanti would silently wrap. See **G-04**.
|
||
|
||
**Recommended order of attack:** language core (arrays/structs/strings) →
|
||
platform (threads/sockets/time) → voxel storage primitive → GPU backend →
|
||
mod ABI → networking. Milestones **M0–M4** are all reachable without
|
||
compromising any of the project's stated constraints (no C, no VM, no
|
||
transpile).
|
||
|
||
---
|
||
|
||
## 1. Evidence base
|
||
|
||
What was read, and how big it is:
|
||
|
||
| Area | Files | LOC | Read |
|
||
|---|---|---|---|
|
||
| `src/` (engine core) | ~700 | 210,370 | headers + all subsystem entry points |
|
||
| `irr/` (Irrlicht fork: renderer, GUI, mesh loaders) | — | 87,554 | structure + driver list |
|
||
| `src/client/` | 134 | 44,247 | `game.cpp`, `content_mapblock.cpp`, `clientmap.cpp`, mesh threads |
|
||
| `src/script/` (Lua bindings) | 147 | 34,240 | full API index, `l_object`, `l_env`, `l_mapgen`, `l_vmanip` |
|
||
| `builtin/` (Lua engine layer) | ~60 | 22,634 | file map, `game/`, `common/`, `emerge/` |
|
||
| `src/gui/` (formspec, menus) | 61 | 19,198 | `guiFormSpecMenu.cpp` (5,610 LOC alone) |
|
||
| `games/devtest` | — | 13,455 | node/item/entity registration patterns |
|
||
| `src/mapgen/` | 32 | 11,668 | all 8 mapgens + biome/ore/decoration/cave/dungeon/schematic |
|
||
| `src/network/` | 23 | 10,958 | protocol enum (90 packets), reliable-UDP impl |
|
||
| `src/util/`, `src/database/`, `src/threading/`, `src/server/` | 102 | 21,961 | serialization, 5 DB backends, thread primitives |
|
||
| `doc/lua_api.md` | 1 | 12,785 | **the full mod-facing contract** — the real spec |
|
||
| `doc/world_format.md` | 1 | 658 | on-disk map format v22–29 |
|
||
|
||
Ludic side, verified by reading source and by compiling probes:
|
||
|
||
| Probe | Result |
|
||
|---|---|
|
||
| recursion (`fib`) | ✅ compiles and links |
|
||
| `"ab" + "cd"` | ❌ front-end accepts, **IR fails to assemble** — no string ops |
|
||
| `mem_alloc` / `peek32` / `poke32` | ✅ works |
|
||
| `fixed` multiply IR | `sext i64 → mul → ashr 16 → trunc i32` — 64-bit intermediate, **i32 result** |
|
||
| `sqrt` / `sin` / `cos` / `atan2` | ❌ absent from compiler and runtime entirely |
|
||
| runtime component attach/detach | ❌ no such statement — components are fixed at `spawn` |
|
||
| entity ceiling | `#define LUDIC_MAX_ENT 1024` (`compiler/native.c:18`) |
|
||
| DEFLATE | **decode only** (`runtime/native/inflate.ludic`) — no compressor |
|
||
|
||
---
|
||
|
||
## 2. What Luanti actually is
|
||
|
||
Nine subsystems. For each: what it does, where it lives, and — the part that
|
||
matters here — **what it demands from the language underneath it.**
|
||
|
||
### 2.1 The voxel core
|
||
|
||
A world is `MapNode`s: a 4-byte struct — `u16 param0` (content id), `u8 param1`
|
||
(two 4-bit light banks, day + night), `u8 param2` (rotation / liquid level /
|
||
level / colour index). 4,096 of them per `MapBlock` (16³). Blocks live in
|
||
sectors; sectors in a `Map`; `ServerMap` and `ClientMap` specialise it.
|
||
|
||
```cpp
|
||
// src/mapnode.h
|
||
struct alignas(u32) MapNode {
|
||
u16 param0; // content_t, up to MAX_REGISTERED_CONTENT (~58k)
|
||
u8 param1; // day light : 4 | night light : 4
|
||
u8 param2; // facedir / wallmounted / liquid level / degrotate / colour
|
||
};
|
||
static constexpr u32 nodecount = 16*16*16; // src/mapblock.h:426
|
||
```
|
||
|
||
**Demands:** a packed struct type; a fixed-size array of them; bitfield
|
||
access; cheap 3D→1D indexing; and the ability to allocate *millions* of these
|
||
(a modest 10-chunk view radius is ~9,000 blocks = 36M nodes = 147 MB).
|
||
|
||
### 2.2 Map storage & persistence
|
||
|
||
`map.sqlite` keyed by a 64-bit interleaved block position; each blob is a
|
||
serialised `MapBlock` — version byte, flags, 12-bit `lighting_complete`
|
||
bitfield, timestamp, a **name↔id mapping** so node ids survive mod changes,
|
||
then `param0`/`param1`/`param2` planes, node metadata, node timers, and static
|
||
objects. Since format 29 the whole block is **zstd-compressed** (zlib before
|
||
that). Five interchangeable backends: SQLite3, LevelDB, Redis, PostgreSQL, flat
|
||
files.
|
||
|
||
**Demands:** big-endian byte serialisation, a compressor (not just a
|
||
decompressor), a string↔id table, and an FFI-usable key/value store.
|
||
|
||
### 2.3 Map generation
|
||
|
||
Eight mapgens (`v5 v6 v7 flat fractal valleys carpathian singlenode`) over a
|
||
shared `Mapgen` base, plus five orthogonal generators layered on top:
|
||
|
||
| Generator | File | LOC | What it does |
|
||
|---|---|---|---|
|
||
| Caves | `cavegen.cpp` | 912 | 3D-noise caverns + tunnel carving |
|
||
| Trees | `treegen.cpp` | 888 | L-system + hardcoded species |
|
||
| Dungeons | `dungeongen.cpp` | 658 | room/corridor placement |
|
||
| Schematics | `mg_schematic.cpp` | 618 | `.mts` blob stamping, force/probability per node |
|
||
| Ores | `mg_ore.cpp` | 583 | scatter / sheet / puff / blob / vein / stratum |
|
||
| Decorations | `mg_decoration.cpp` | 472 | simple / schematic / lsystem placement |
|
||
| Biomes | `mg_biome.cpp` | 332 | heat+humidity noise → biome, per-node depth layers |
|
||
|
||
All of it rests on **fractal value noise**:
|
||
|
||
```cpp
|
||
// src/noise.h
|
||
struct NoiseParams {
|
||
float offset = 0.0f, scale = 1.0f;
|
||
v3f spread = v3f(250,250,250);
|
||
s32 seed = 12345;
|
||
u16 octaves = 3;
|
||
float persist = 0.6f, lacunarity = 2.0f;
|
||
u32 flags = NOISE_FLAG_DEFAULTS;
|
||
};
|
||
float NoiseFractal3D(const NoiseParams *np, float x, float y, float z, s32 seed);
|
||
```
|
||
|
||
**Demands:** floating-point (or ≥Q32.32 fixed), a noise map buffer type,
|
||
and — because mapgen runs on `EmergeThread`s — **real threads plus a
|
||
thread-local `MMVManip` voxel buffer.**
|
||
|
||
### 2.4 Environment & simulation
|
||
|
||
`ServerEnvironment` drives the world tick: **ABMs** (Active Block Modifiers —
|
||
probabilistic per-node rules that run in loaded blocks), **LBMs** (Loading Block
|
||
Modifiers — run once when a block loads), **node timers**, liquid transformation
|
||
queues, and object activation/deactivation as players move.
|
||
|
||
```lua
|
||
core.register_abm({
|
||
label = "Lava cooling",
|
||
nodenames = {"default:lava_source"},
|
||
neighbors = {"default:water_source", "default:water_flowing"},
|
||
interval = 10.0, -- seconds
|
||
chance = 50, -- 1-in-50 per node per interval
|
||
min_y = -32768, max_y = 32767,
|
||
catch_up = true,
|
||
action = function(pos, node, active_object_count, active_object_count_wider) ... end,
|
||
})
|
||
```
|
||
|
||
**This is the one Luanti concept that maps *beautifully* onto Ludic.** An ABM is
|
||
literally a system with a query, a `where` clause and a probability. See G-16 —
|
||
this is where Ludic could be *better* than Luanti, not merely equal.
|
||
|
||
### 2.5 Objects, entities, players
|
||
|
||
`ActiveObject` → `ServerActiveObject` → `LuaEntitySAO` / `PlayerSAO`; mirrored
|
||
client-side by `ClientActiveObject` → `GenericCAO` (`content_cao.cpp`, 2,002
|
||
LOC). ~30 shared `ObjectProperties` (visual, mesh, textures, `collisionbox`,
|
||
`selectionbox`, `physical`, `stepheight`, `nametag`, …). `ObjectRef` alone
|
||
exposes **123 documented methods** in `lua_api.md`.
|
||
|
||
Collision is swept-AABB against nodes and other objects
|
||
(`collisionMoveSimple`), with `stepheight`, and a separate `Raycast` iterator
|
||
and an A* `pathfinder.cpp` (1,419 LOC).
|
||
|
||
**Demands:** float vectors, AABB math, `sqrt`, and dynamic per-entity component
|
||
sets (an entity gains/loses attachments, nametags, physics at runtime).
|
||
|
||
### 2.6 Items, inventory, crafting
|
||
|
||
`ItemDefManager`, `ItemStack` (name + count + wear + metadata), `InvRef` lists,
|
||
`craftdef.cpp` (1,250 LOC) with shaped / shapeless / toolrepair / cooking /
|
||
fuel recipe types, and **groups** — the string→int tag system that everything
|
||
(digging times, damage, biome placement) keys off:
|
||
|
||
```lua
|
||
groups = {cracky = 3, level = 2, not_in_creative_inventory = 1}
|
||
tool_capabilities = {
|
||
full_punch_interval = 1.0, max_drop_level = 1,
|
||
groupcaps = { cracky = {times = {[1]=2.0, [2]=1.0, [3]=0.5}, uses = 20, maxlevel = 2} },
|
||
damage_groups = {fleshy = 2},
|
||
}
|
||
```
|
||
|
||
**Demands:** string-keyed maps, dynamic arrays, and a runtime registry.
|
||
|
||
### 2.7 Client rendering
|
||
|
||
The heaviest subsystem. `content_mapblock.cpp` (1,870 LOC) turns a MapBlock
|
||
into a mesh, one branch per **drawtype** — there are 19:
|
||
|
||
```
|
||
NORMAL AIRLIKE LIQUID FLOWINGLIQUID GLASSLIKE ALLFACES ALLFACES_OPTIONAL
|
||
TORCHLIKE SIGNLIKE PLANTLIKE FENCELIKE RAILLIKE NODEBOX GLASSLIKE_FRAMED
|
||
FIRELIKE GLASSLIKE_FRAMED_OPTIONAL MESH PLANTLIKE_ROOTED
|
||
```
|
||
|
||
Around it: `mapblock_mesh.cpp` + `mesh_generator_thread.cpp` (background
|
||
meshing), `clientmap.cpp` (frustum culling, transparency sorting),
|
||
`shader.cpp`, 20 GLSL shader programs (`client/shaders/`) covering nodes,
|
||
objects, bloom down/upsample, FXAA, volumetric light, shadow mapping,
|
||
exposure adaptation, sky, clouds, minimap, and `imagesource.cpp` (1,915 LOC)
|
||
implementing the **texture modifier mini-language** (`default_dirt.png^grass.png^[opacity:160`).
|
||
|
||
**Demands:** a GPU. Vertex/index buffers, texture atlases + mipmaps, a shader
|
||
pipeline, matrices, frustum math. None of this exists in Ludic, whose renderer
|
||
is a `320×240` `i32` framebuffer blitted through Core Graphics.
|
||
|
||
### 2.8 GUI: formspec, HUD, chat
|
||
|
||
`guiFormSpecMenu.cpp` is the single largest file in the engine at **5,610 LOC**.
|
||
It parses a string DSL with **64 element types** (`list`, `field`, `dropdown`,
|
||
`scroll_container`, `hypertext`, `model`, `tabheader`, `style`, `table`, …):
|
||
|
||
```lua
|
||
core.show_formspec(name, "mymod:chest", table.concat({
|
||
"formspec_version[7]", "size[8,9]",
|
||
"list[context;main;0.375,0.75;8,4;]",
|
||
"list[current_player;main;0.375,5.5;8,4;]",
|
||
"listring[context;main]",
|
||
}))
|
||
```
|
||
|
||
Plus a HUD element system (`hud_element.h`), a chat console, and a markup
|
||
`hypertext` renderer (`guiHyperText.cpp`, 1,254 LOC).
|
||
|
||
**Ludic's `ui` block is genuinely the right shape for this** — retained widget
|
||
tree as data — but covers ~8 widget types against 64, has no scrolling, no
|
||
tables, no text input, and no inventory-slot widget.
|
||
|
||
### 2.9 Networking
|
||
|
||
Custom reliable-UDP over a channel abstraction (`src/network/mtp/impl.cpp`,
|
||
1,685 LOC + `threads.cpp`, 1,429 LOC): split packets, reliable/unreliable
|
||
delivery, ack windows, peer timeout. **90 packet types**
|
||
(`ToClientCommand` / `ToServerCommand`), protocol version floor 37. Server
|
||
sends mapblocks by distance priority, streams media by hash, and runs an auth
|
||
handshake (SRP).
|
||
|
||
**Demands:** UDP sockets, threads, timers, checksums, and compression.
|
||
|
||
### 2.10 Audio
|
||
|
||
OpenAL + Ogg Vorbis (`src/client/sound/`, 11 files): 3D positional sound,
|
||
fading, looping, per-object attachment, a proxy manager so the audio thread
|
||
never blocks the main loop.
|
||
|
||
**Ludic has no audio of any kind.** Not a builtin, not a runtime primitive.
|
||
|
||
### 2.11 Scripting — the actual design centre
|
||
|
||
This is the part that is easy to under-weight. **Luanti is not a game; it is a
|
||
Lua host.** `games/devtest` and Minetest Game are *mods*. The C++ engine's
|
||
purpose is to expose:
|
||
|
||
- **321** `core.*` functions
|
||
- **31** `core.register_on_*` global callbacks
|
||
- **19** documented classes (`ObjectRef`, `ItemStack`, `InvRef`, `VoxelManip`,
|
||
`NodeMetaRef`, `PcgRandom`, `ValueNoiseMap`, `AreaStore`, `Settings`, …)
|
||
- **24** definition-table schemas (node, item, entity, ABM, LBM, biome, ore,
|
||
decoration, particle, HUD, craft, …)
|
||
- a security sandbox (`s_security.cpp`, 1,253 LOC) that whitelists the Lua
|
||
stdlib and confines file access to the mod's own directory
|
||
- an async job environment, a mapgen-thread environment, and **SSCSM**
|
||
(server-sent client-side mods)
|
||
|
||
Mods are hot-loaded at runtime from directories, in dependency order, and can
|
||
be added by a server operator without recompiling anything.
|
||
|
||
**This is the deepest architectural gap**, and it collides head-on with a stated
|
||
project constraint (compiled/native only; no VM, no interpreter). Resolved in
|
||
**G-25** — the answer is a compiled mod ABI, not a scripting language.
|
||
|
||
### 2.12 Support layers
|
||
|
||
Threads + mutex + semaphore + event (`src/threading/`), `SettingsManager`,
|
||
gettext i18n with plural forms and per-server translations, `httpfetch` (cURL),
|
||
JSON, SRP auth, `Profiler`, `AreaStore` (spatial index), zlib **and** zstd,
|
||
GMP for bignum crypto.
|
||
|
||
External dependencies Luanti links: `SQLite3 Lua CURL Freetype GettextLib
|
||
OpenAL Vorbis OpenSSL PostgreSQL ZLIB Zstd GMP Json Threads Ncursesw`.
|
||
|
||
---
|
||
|
||
## 3. Ludic today — verified capability inventory
|
||
|
||
Everything below was confirmed against the compiler source, not the docs.
|
||
|
||
**Types:** `int` (i32), `fixed` (Q16.16 in i32), `bool` (i32), `entity` (i32),
|
||
`str` (`ptr` to literal), `ptr` (opaque), `void`. That is the complete list
|
||
(`compiler/ludicc.c:516`).
|
||
|
||
**Declarations:** `game`, `module`/`export`, `import`, `component`, `archetype`,
|
||
`const`, `var` (module-level globals — yes, these exist), `fn`, `extern fn`,
|
||
`system`, `scene`/`layer`, `ui`.
|
||
|
||
**Statements:** `let`, assignment (`= += -= *= /=`), `if`/`else`, `when`,
|
||
`while`, `for i in a..b`, `for (…) in query […] where …`, `return`, `spawn`,
|
||
`despawn`, `match`, `machine`/`become`, `enter`.
|
||
|
||
**ECS:** dense per-component arrays sized `LUDIC_MAX_ENT = 1024`, a parallel
|
||
`@H_<Comp>` byte per entity for "has", `@L_kind` for archetype, `@L_alive`, a
|
||
free list. Queries are **linear scans over all 1,024 slots** re-checking the
|
||
mask each visit.
|
||
|
||
**Intrinsics** (the floor, lowering to libc/OS symbols):
|
||
`mem_alloc mem_free mem_copy mem_set peek8 poke8 peek32 poke32 ptr_add ptr_null
|
||
ptr_is_null file_open file_read file_write file_seek file_tell file_close
|
||
read_byte write_byte print_str str_len os_exit os_time shl shr band bor bxor
|
||
bnot peekp pokep peekf pokef as_fixed as_int is_windowed game_title win_open
|
||
win_poll win_present win_running win_close`
|
||
|
||
**Runtime (written in Ludic):** framebuffer + 2D draw, 5×7 bitmap text, a
|
||
from-scratch TrueType engine (Q16.16 béziers, AA), PNG decode incl. DEFLATE
|
||
*inflate*, sprites + 9-slice, tilemap, xorshift RNG, 64 int registers, retained
|
||
UI (panel/col/row/label/button/image/spacer), whole-world save/load snapshot.
|
||
|
||
**Confirmed absent:** arrays · structs as values · strings (any operation) ·
|
||
floats · integers wider or narrower than 32 bits · unsigned · enums · unions ·
|
||
generics · closures · function pointers · maps/dicts · slices · bounds checks ·
|
||
error handling · `sqrt`/trig · vectors/matrices · threads · atomics · sockets ·
|
||
audio · GPU · dynamic loading · runtime component attach/detach · a compressor ·
|
||
any entity count above 1,024.
|
||
|
||
---
|
||
|
||
## 4. Gap register — summary
|
||
|
||
Tiers are dependency-ordered: nothing in tier *n* is buildable before tier *n−1*.
|
||
|
||
| # | Gap | Tier | Blocks | Effort |
|
||
|---|---|---|---|---|
|
||
| G-01 | Array & slice types | 0 core | everything | L |
|
||
| G-02 | Struct / record value types | 0 core | MapNode, vectors, defs | L |
|
||
| G-03 | Strings (owned, UTF-8, ops) | 0 core | names, metadata, formspec | L |
|
||
| G-04 | Wide numerics: `f32`/`f64` or Q32.32 | 0 core | **world coordinates** | M |
|
||
| G-05 | Sized & unsigned ints (`u8 u16 i64`) | 0 core | node packing, serialization | M |
|
||
| G-06 | Enums & tagged unions | 0 core | drawtypes, packets | M |
|
||
| G-07 | Math library (`sqrt`, trig, `atan2`) | 0 core | physics, noise, camera | S |
|
||
| G-08 | Allocator/arena discipline + bounds checks | 0 core | 147 MB of voxels | M |
|
||
| G-09 | Error handling (`result`/`try`) | 0 core | I/O, net, parsing | M |
|
||
| G-10 | Generics or monomorphised containers | 0 core | every collection | L |
|
||
| G-11 | Hash maps | 0 core | registries, name↔id | M |
|
||
| G-12 | Entity count beyond 1,024; chunked ECS storage | 1 ecs | any real world | L |
|
||
| G-13 | Runtime component attach/detach | 1 ecs | entity properties | S |
|
||
| G-14 | Multiple worlds / ECS instances | 1 ecs | client+server in one binary | M |
|
||
| G-15 | Query indices (no linear scan) | 1 ecs | performance | M |
|
||
| G-16 | Spatial/probabilistic systems (ABM-shaped) | 1 ecs | world simulation | M |
|
||
| G-17 | Threads, mutexes, atomics, channels | 2 plat | mapgen, meshing, net | L |
|
||
| G-18 | Sockets (UDP + TCP) | 2 plat | multiplayer | M |
|
||
| G-19 | Monotonic + wall clock, sleep, timers | 2 plat | fixed timestep | S |
|
||
| G-20 | Dynamic library loading at runtime | 2 plat | mods | S |
|
||
| G-21 | Compression (deflate **encode**, zstd) | 2 plat | map storage, net | M |
|
||
| G-22 | Voxel chunk primitive + VoxelManip | 3 voxel | the game itself | L |
|
||
| G-23 | Lighting propagation & mesh generation | 3 voxel | rendering | L |
|
||
| G-24 | Collision, raycast, AABB, pathfinding | 3 voxel | movement | M |
|
||
| G-25 | **Mod/extension model (compiled ABI)** | 4 ext | Luanti's whole point | XL |
|
||
| G-26 | Data-driven registries & definition tables | 4 ext | node/item defs | M |
|
||
| G-27 | Metadata stores (node/item/player) | 4 ext | chests, signs | M |
|
||
| G-28 | GPU backend: 3D pipeline, shaders | 5 gfx | 3D at all | XL |
|
||
| G-29 | 3D math types (vec/mat/quat/aabb) | 5 gfx | everything 3D | M |
|
||
| G-30 | UI: 64 formspec elements, scroll, input, tables | 5 gfx | inventories | L |
|
||
| G-31 | Audio: mixing, Ogg decode, 3D positional | 6 av | polish | L |
|
||
| G-32 | i18n: gettext, plurals, server translations | 6 av | shipping | M |
|
||
| G-33 | Persistence: versioned block format, SQLite FFI | 7 net | saving | M |
|
||
| G-34 | Network protocol: reliable UDP, 90 packets | 7 net | multiplayer | XL |
|
||
|
||
`S ≈ days · M ≈ 1–3 weeks · L ≈ 1–2 months · XL ≈ a quarter+` (single developer.)
|
||
|
||
---
|
||
|
||
## 5. The gaps in detail
|
||
|
||
Each entry: what Luanti needs · what Ludic has · proposed Ludic feature · the
|
||
compiler/runtime work it implies.
|
||
|
||
### Tier 0 — Language core
|
||
|
||
#### G-01 · Array & slice types
|
||
|
||
**Luanti needs.** `MapNode data[4096]` per block; `std::vector<aabb3f>` per
|
||
nodebox; noise buffers of `w*h*d` floats; the 6 tiledefs per node.
|
||
|
||
**Ludic has.** Nothing. `runtime/native/core.ludic` fakes arrays with
|
||
`mem_alloc` + `peek32`/`poke32` and manual stride arithmetic:
|
||
|
||
```ludic
|
||
rt_fb = mem_alloc(320 * 240 * 4)
|
||
poke32(rt_fb, (y * rt_fbw + x) * 4, color) # every access, by hand
|
||
```
|
||
|
||
That is workable for a 3,700-line runtime and fatal for a 100,000-line engine:
|
||
no bounds checks, no element type, no length.
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
# fixed-size, stack or inline in a struct — length is part of the type
|
||
let box: [16]int
|
||
|
||
# heap-allocated, length carried, bounds-checked in debug builds
|
||
let nodes: [] MapNode = alloc [4096] MapNode
|
||
nodes[i].param0 = C_STONE
|
||
print_int(nodes.len)
|
||
|
||
# slices: a (ptr, len) pair, no ownership — the workhorse for I/O
|
||
function checksum(bytes: []u8) -> int {
|
||
var sum = 0
|
||
for b in bytes { sum = sum + b } # `for x in slice` iteration
|
||
return sum
|
||
}
|
||
let window = nodes[0 .. 256] # slicing, no copy
|
||
```
|
||
|
||
**Work.** Lexer: `[`/`]` in type position. Types: a new `TARR{elem, len}` and
|
||
`TSLICE{elem}`. Codegen: LLVM `[N x T]` and `{ptr, i64}`; `getelementptr` for
|
||
indexing; a `panic_bounds` intrinsic. Semantics decision: slices are non-owning,
|
||
`alloc` returns an owning array that `free` releases. **This is the single
|
||
highest-leverage item in the document** — G-02, G-03, G-05, G-11, G-22 all
|
||
depend on it.
|
||
|
||
#### G-02 · Struct / record value types
|
||
|
||
**Luanti needs.** `MapNode` is a 4-byte value passed by copy 10⁶ times a frame.
|
||
`v3s16`, `aabb3f`, `TileDef`, `NoiseParams`, `ObjectProperties`, `CollisionInfo`.
|
||
|
||
**Ludic has.** `component`, which is *not* a value type — it is a row in a
|
||
global ECS array, addressable only through a query binding. You cannot declare
|
||
a local of component type, return one, or nest one.
|
||
|
||
**Proposed.** Split "data shape" from "ECS membership":
|
||
|
||
```ludic
|
||
struct MapNode {
|
||
param0: u16 = 0 # content id
|
||
param1: u8 = 0 # light: day|night nibbles
|
||
param2: u8 = 0 # facedir / level / liquid
|
||
}
|
||
|
||
struct v3i { x: int = 0, y: int = 0, z: int = 0 }
|
||
|
||
function index(p: v3i) -> int { return (p.z * 16 + p.y) * 16 + p.x }
|
||
|
||
function get(blk: []MapNode, p: v3i) -> MapNode { return blk[index(p)] }
|
||
|
||
# a component becomes "a struct that lives in the world"
|
||
component Pos = v3i # aliasing form
|
||
component Vel { dx: fixed = 0, dy: fixed = 0 } # existing form still valid
|
||
```
|
||
|
||
Add `expr with { field = … }` (already flagged "not implemented" in
|
||
LANGUAGE.md) so records can be updated functionally:
|
||
|
||
```ludic
|
||
let lit = n with { param1 = pack_light(15, 15) }
|
||
```
|
||
|
||
**Work.** `NSTRUCT` decl, struct types in the type table, LLVM named struct
|
||
types, by-value passing/returning (`byval` for large ones), field access on
|
||
non-component values, `with` lowering to insertvalue chains.
|
||
|
||
#### G-03 · Strings
|
||
|
||
**Luanti needs.** Node names (`"default:stone"`), item strings
|
||
(`"default:pick_steel 1 250"`), formspec strings built by concatenation, chat,
|
||
metadata keys, mod names, translation keys, the texture-modifier DSL.
|
||
|
||
**Ludic has.** `str` = a pointer to a literal. Verified: `"ab" + "cd"` passes
|
||
the parser and then **fails to assemble**. `str_len` exists as an intrinsic;
|
||
nothing else does.
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
# `str` stays an immutable literal/slice: (ptr, len), UTF-8, no allocation
|
||
# `string` is owned and growable
|
||
let name: str = "default:stone"
|
||
var buf: string = string_new()
|
||
buf.push("size[8,9]")
|
||
buf.push_fmt("list[context;main;{};{};8,4;]", x, y)
|
||
let out: str = buf.view()
|
||
|
||
if name.starts_with("default:") { … }
|
||
let (modname, item) = name.split_once(':')
|
||
let h = name.hash() # for the registry map
|
||
for cp in name.codepoints() { … } # UTF-8 aware
|
||
```
|
||
|
||
**Work.** Interning table for literals (already partly there for `.str` globals),
|
||
a `string` runtime type in `runtime/native/string.ludic`, `+`/`==`/`<` operator
|
||
lowering for `str`, formatting (`push_fmt`) which needs varargs or a small
|
||
format-node lowering. Note the TrueType engine already decodes UTF-8 — reuse it.
|
||
|
||
#### G-04 · Wide numerics — **the coordinate bug**
|
||
|
||
**Luanti needs.** `f32` positions in BS units. `MAX_MAP_GENERATION_LIMIT =
|
||
31007` nodes; `BS = 10.0f`; so world coordinates reach **±310,070**, and squared
|
||
distances reach **~10⁹**.
|
||
|
||
**Ludic has.** Q16.16 in i32. Range **±32,767.99998**. Verified IR:
|
||
|
||
```llvm
|
||
%t2 = sext i32 %t0 to i64 ; 64-bit intermediate — precision is fine
|
||
%t3 = sext i32 %t1 to i64
|
||
%t4 = mul i64 %t2, %t3
|
||
%t5 = ashr i64 %t4, 16
|
||
%t6 = trunc i64 %t5 to i32 ; …but the result truncates back to Q16.16
|
||
```
|
||
|
||
So `fixed` **cannot hold a Luanti world coordinate at all**, and `d = dx*dx +
|
||
dy*dy + dz*dz` wraps silently for anything past ~181 nodes from the origin.
|
||
Every collision test, raycast, and mob AI decision would be wrong far from
|
||
spawn.
|
||
|
||
**Proposed — pick one (recommendation: both, in this order):**
|
||
|
||
1. **`fixed64` / Q32.32 on i64** — keeps determinism, which is a genuine
|
||
advantage for a multiplayer voxel game (bit-identical physics across
|
||
platforms; Luanti itself suffers from float drift here).
|
||
```ludic
|
||
let px: fixed64 = 310070.5 # ±2.1e9 range, 2⁻³² precision
|
||
```
|
||
2. **`f32` / `f64`** — required anyway for mapgen noise (`NoiseParams` is all
|
||
floats and the noise functions are transcendental), and for GPU interop where
|
||
vertex data *must* be IEEE floats.
|
||
```ludic
|
||
let h: f32 = noise3d(x, y, z, seed)
|
||
```
|
||
|
||
**Work.** New scalar types in the type table; LLVM `i64` / `float` / `double`;
|
||
promotion rules (`int → fixed → fixed64`, `int → f32 → f64`); literal suffixes
|
||
(`1.5f`, `1.5d`); `fmul`/`fdiv`/`fcmp` lowering; printf format selection.
|
||
|
||
#### G-05 · Sized & unsigned integers
|
||
|
||
**Luanti needs.** `MapNode` is `u16 + u8 + u8` — exactly 4 bytes, because 36
|
||
million of them exist. Serialisation is byte-exact and big-endian. `content_t`
|
||
is `u16`; light is two 4-bit nibbles.
|
||
|
||
**Ludic has.** i32 only. A `MapNode` built from Ludic `int`s would be 12 bytes —
|
||
**3× the memory**, 441 MB instead of 147 MB for a 10-block view radius.
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
struct MapNode { param0: u16, param1: u8, param2: u8 } # exactly 4 bytes
|
||
|
||
let light_day: u8 = band(n.param1, 0x0f)
|
||
let content: u16 = n.param0
|
||
let key: u64 = block_key(bp) # sqlite position encoding
|
||
```
|
||
|
||
Plus explicit conversion (`as u8`, `as int`) and wrap/saturate semantics stated
|
||
in the spec.
|
||
|
||
**Work.** Type table entries for `u8 u16 u32 u64 i8 i16 i64`; LLVM `zext`/`sext`
|
||
/`trunc` at boundaries; struct layout + alignment rules; unsigned comparison and
|
||
shift (`lshr` vs `ashr` — the existing `shr` intrinsic already lowers to `lshr`,
|
||
which is wrong for signed ints, and would become type-directed).
|
||
|
||
#### G-06 · Enums & tagged unions
|
||
|
||
**Luanti needs.** 19 `NodeDrawType`s, 90 packet types, `LiquidType`,
|
||
`ContentParamType`, `CollisionType`, and `pointed_thing` which is a genuine sum
|
||
type (`nothing` | `node{under,above}` | `object{ref}`).
|
||
|
||
**Ludic has.** `const` ints, and `match` over int literals (which is already
|
||
half of what's needed and works well).
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
enum DrawType { Normal, Airlike, Liquid, FlowingLiquid, Glasslike, Allfaces,
|
||
Torchlike, Signlike, Plantlike, Fencelike, Raillike, Nodebox,
|
||
GlasslikeFramed, Firelike, Mesh, PlantlikeRooted }
|
||
|
||
union Pointed {
|
||
Nothing
|
||
Node { under: v3i, above: v3i }
|
||
Object { ref: entity }
|
||
}
|
||
|
||
match p {
|
||
Pointed.Nothing => return
|
||
Pointed.Node(u, a) => place_node(a, item) # destructuring arms
|
||
Pointed.Object(e) => punch(e)
|
||
}
|
||
```
|
||
|
||
`match` already exists and already lowers on the native backend — extending its
|
||
arms to enum cases and destructuring patterns is incremental, not new
|
||
machinery.
|
||
|
||
#### G-07 · Math library
|
||
|
||
**Luanti needs.** `sqrt` (every distance), `sin`/`cos` (rotation, sky, camera),
|
||
`atan2` (`automatic_face_movement_dir`), `pow`/`exp` (noise persistence,
|
||
exposure adaptation), `floor`/`ceil`/`round`.
|
||
|
||
**Ludic has.** `min max abs clamp` — integer only. **Verified: no `sqrt`
|
||
anywhere in the compiler or the runtime.**
|
||
|
||
**Proposed.** A `runtime/native/math.ludic` written in Ludic (consistent with
|
||
the project's "runtime is in Ludic" rule) — CORDIC or polynomial approximations
|
||
for trig on `fixed`, Newton–Raphson `sqrt`, plus direct `llvm.sqrt.f32` /
|
||
`llvm.sin.f32` intrinsic lowering once `f32` lands (G-04).
|
||
|
||
```ludic
|
||
sqrt(x: fixed) -> fixed sin(a: fixed) -> fixed atan2(y, x) -> fixed
|
||
pow(b, e) -> fixed floor(f) -> int lerp(a, b, t) -> fixed
|
||
```
|
||
|
||
**Effort: S.** This one is a weekend, and unblocks a surprising amount.
|
||
|
||
#### G-08 · Memory discipline
|
||
|
||
**Luanti needs.** A 10-block view radius is ~9,000 MapBlocks ≈ **147 MB of node
|
||
data alone**, churning constantly as blocks load and unload.
|
||
|
||
**Ludic has.** `mem_alloc`/`mem_free` (raw `malloc`/`free`), no ownership
|
||
tracking, no bounds checks, no leak detection. `runtime/native/image.ludic`
|
||
already leaks decoded PNG buffers by design.
|
||
|
||
**Proposed.** Not a borrow checker — that is a different language. An **arena
|
||
and a pool**, expressed in Ludic, plus optional bounds checking:
|
||
|
||
```ludic
|
||
arena frame # reset once per frame, no individual frees
|
||
let verts = frame.alloc [4096] Vertex
|
||
|
||
pool blocks: MapBlock # fixed-size slab, free-list backed
|
||
let b = blocks.take()
|
||
blocks.give(b)
|
||
|
||
# debug builds: every [] access bounds-checked; --release drops the check
|
||
```
|
||
|
||
#### G-09 · Error handling
|
||
|
||
**Luanti needs.** Corrupt map blobs, truncated packets, missing textures,
|
||
unknown node names, failed DB writes. It uses C++ exceptions
|
||
(`src/exceptions.h`) plus sentinel returns.
|
||
|
||
**Ludic has.** Nothing. `file_open` returns a `ptr` you must test with
|
||
`ptr_is_null`; every failure path is a hand-rolled convention.
|
||
|
||
**Proposed.** No unwinding (it would fight the AOT/no-runtime philosophy) —
|
||
sum-typed results with sugar, which falls straight out of G-06:
|
||
|
||
```ludic
|
||
function read_block(path: str) -> MapBlock ! IoError {
|
||
let f = file_open(path, "rb") else return IoError.NotFound
|
||
let n = try file_read(f, buf, 4096) # `try` propagates the error arm
|
||
return parse(buf[0..n])
|
||
}
|
||
|
||
match read_block(p) {
|
||
Ok(b) => use(b)
|
||
Err(e) => log_warn("block load failed", e)
|
||
}
|
||
```
|
||
|
||
#### G-10 · Generics (or monomorphised containers)
|
||
|
||
Without some form of parametric types, every container is written N times.
|
||
Minimum viable: **compile-time monomorphisation over one type parameter**, no
|
||
constraints, no inference beyond the call site.
|
||
|
||
```ludic
|
||
struct Vec[T] { data: []T, len: int, cap: int }
|
||
|
||
fn push[T](v: Vec[T], x: T) -> void {
|
||
if v.len == v.cap { v.data = realloc(v.data, v.cap * 2) v.cap = v.cap * 2 }
|
||
v.data[v.len] = x
|
||
v.len = v.len + 1
|
||
}
|
||
|
||
var objects: Vec[entity] = vec_new[entity]()
|
||
```
|
||
|
||
**Work.** Parse `[T]` in decls and calls; a monomorphisation pass keyed on
|
||
(fn, concrete args) before codegen; name mangling. Deliberately *not* traits,
|
||
*not* variance, *not* HKT — this is a game language, and Luanti's C++ uses
|
||
templates in exactly this restrained way.
|
||
|
||
#### G-11 · Hash maps
|
||
|
||
**Luanti needs.** name→id for nodes and items (~60k entries), the definition
|
||
registries, `NodeMetaRef` string→string stores, groups (`string → int`), mod
|
||
storage, translation catalogues.
|
||
|
||
**Proposed.** One monomorphised open-addressing map, in Ludic:
|
||
|
||
```ludic
|
||
var by_name: Map[str, u16] = map_new[str, u16]()
|
||
by_name.set("default:stone", 1)
|
||
if let id = by_name.get("default:stone") { … }
|
||
for (k, v) in by_name { … }
|
||
```
|
||
|
||
### Tier 1 — ECS at world scale
|
||
|
||
#### G-12 · The 1,024-entity ceiling
|
||
|
||
**The number.** `compiler/native.c:18` — `#define LUDIC_MAX_ENT 1024`. Every
|
||
component becomes `@S_<Comp> = internal global [1024 x %Cmp_<Comp>]`, plus a
|
||
`[1024 x i8]` presence array. Storage is **static, dense, and allocated for
|
||
every component whether used or not.**
|
||
|
||
**What Luanti needs.** A busy server carries thousands of active objects; a
|
||
single 16³ MapBlock holds 4,096 nodes. Nodes must *not* be ECS entities (see
|
||
G-22), but mobs, items, players, particles, and node timers all should be.
|
||
|
||
**Proposed.** Two changes, independent:
|
||
|
||
1. **Configurable + dynamic capacity.** `game Foo { entities 65536 }` for the
|
||
static bound, and a growth path: component arrays become heap
|
||
`[]Cmp` that double.
|
||
2. **Sparse-set storage** (the standard ECS answer): per component a dense
|
||
packed array + a sparse index. Iteration cost becomes proportional to the
|
||
number of entities *with that component*, not to 1,024. This also kills the
|
||
memory waste: a component held by 10 entities stops costing 1,024 slots.
|
||
|
||
```ludic
|
||
game Voxel {
|
||
entities 262144 # ceiling, or `dynamic` for growable
|
||
storage sparse # or `dense` for the current behaviour
|
||
}
|
||
```
|
||
|
||
#### G-13 · Runtime component attach/detach
|
||
|
||
**Luanti needs.** An entity gains a nametag, loses physics, gets attached to a
|
||
vehicle, picks up an inventory — all at runtime. `ObjectRef:set_properties`,
|
||
`set_attach`, `set_armor_groups` are per-object mutations of the *shape* of the
|
||
entity.
|
||
|
||
**Ludic has.** Components are attached only by `spawn`. There is no statement to
|
||
add or remove one afterwards. (Verified: no `attach`/`detach` in the parser.)
|
||
The `@H_<Comp>` presence byte already exists — the storage supports it; the
|
||
*language* does not expose it.
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
attach Nametag { text = "Zombie", color = 0xffffff } to e
|
||
detach Physics from e
|
||
if has Physics on e { … }
|
||
```
|
||
|
||
**Effort: S** — the runtime representation is already there.
|
||
|
||
#### G-14 · Multiple ECS worlds
|
||
|
||
Luanti runs a `ServerEnvironment` and a `ClientEnvironment` **in the same
|
||
process** for singleplayer. Ludic has exactly one implicit global world.
|
||
|
||
```ludic
|
||
world Server { … } # separate component storage per world
|
||
world Client { … }
|
||
for (p) in Server.query [Pos] { … }
|
||
```
|
||
|
||
Also needed for: mapgen worker threads that build a block in isolation, and for
|
||
tests.
|
||
|
||
#### G-15 · Query indices
|
||
|
||
Today: `for (p) in query [Pos]` scans slots 0..1023 and re-tests the mask.
|
||
With G-12's higher ceilings that becomes the hot loop. Sparse sets (G-12) fix
|
||
the common case; the remaining need is **multi-component intersection** — pick
|
||
the smallest dense set and probe the others.
|
||
|
||
#### G-16 · ABM-shaped systems — *where Ludic can beat Luanti*
|
||
|
||
Luanti's ABM is a hand-rolled sampler in C++: pick random positions in loaded
|
||
blocks, test `nodenames`, test `neighbors`, roll `chance`, call a Lua closure.
|
||
It is one of the most-profiled parts of the engine.
|
||
|
||
In Ludic this is a **declaration**:
|
||
|
||
```ludic
|
||
system LavaCooling phase Update
|
||
every 10.0s # interval, not a hand-rolled timer
|
||
chance 1 in 50 # sampler, compiled in
|
||
nodes [C_LAVA_SOURCE] # content filter → an index, not a scan
|
||
near [C_WATER_SOURCE, C_WATER_FLOWING]
|
||
in y -32768 .. 32767
|
||
{
|
||
set_node(self_pos(), C_OBSIDIAN)
|
||
}
|
||
```
|
||
|
||
The compiler knows the node set at compile time, so it can build the per-block
|
||
content index once and iterate only matching positions — an optimisation Luanti
|
||
cannot do because its ABM predicates arrive as runtime strings. **This is the
|
||
strongest argument in the whole document for the language existing.**
|
||
|
||
### Tier 2 — Platform
|
||
|
||
#### G-17 · Threads, mutexes, atomics
|
||
|
||
**Luanti needs.** `EmergeThread` × N (mapgen), `MeshUpdateThread` (client
|
||
meshing), `ConnectionSendThread` + `ConnectionReceiveThread`, the sound proxy
|
||
thread, and an async Lua job environment. `src/threading/` provides
|
||
`Thread`, `Mutex`, `Semaphore`, `Event`, `OrderedMutex`.
|
||
|
||
**Ludic has.** Nothing. No `pthread_create` binding, no atomics, and — a real
|
||
problem — **`var` globals are unsynchronised i32 stores** and the ECS arrays are
|
||
process-global.
|
||
|
||
**Proposed.**
|
||
|
||
```ludic
|
||
thread pool emerge count 4 { # a named pool with a typed job queue
|
||
job Generate(bp: v3i) -> MapBlock { … }
|
||
}
|
||
emerge.submit(Generate(bp))
|
||
if let blk = emerge.poll() { install(blk) }
|
||
|
||
atomic var block_count: int = 0
|
||
block_count.add(1)
|
||
|
||
mutex map_lock
|
||
lock map_lock { … }
|
||
|
||
channel Chan[MapBlock] results # MPSC, blocking or try_recv
|
||
```
|
||
|
||
Design steer: prefer **message passing + job pools** over shared mutable state,
|
||
because it composes with the ECS (a job owns its world slice) and because it is
|
||
what a determinism-oriented language should encourage.
|
||
|
||
#### G-18 · Sockets
|
||
|
||
UDP is mandatory (the whole protocol is UDP), TCP + TLS for the content store
|
||
and HTTP mods.
|
||
|
||
```ludic
|
||
let s = udp_bind("0.0.0.0", 30000)
|
||
let (n, from) = s.recv(buf)
|
||
s.send_to(from, reply)
|
||
```
|
||
|
||
Bind at the syscall level from the runtime, as with `file_open` — `socket`,
|
||
`bind`, `sendto`, `recvfrom`, `poll`. No new language feature required beyond
|
||
G-01 (slices) and G-09 (errors), which is why this is M, not L.
|
||
|
||
#### G-19 · Time
|
||
|
||
`os_time()` returns whole seconds. A game loop needs a **monotonic** clock at
|
||
microsecond resolution, and Luanti's `DTIME_LIMIT = 2.5f` / fixed-timestep
|
||
stepping depends on it.
|
||
|
||
```ludic
|
||
let t = now_us() # monotonic, u64
|
||
sleep_ms(2)
|
||
```
|
||
|
||
#### G-20 · Dynamic loading
|
||
|
||
`dlopen`/`dlsym` equivalents, so mods (G-25) can be loaded from a directory at
|
||
startup without relinking the engine.
|
||
|
||
```ludic
|
||
let m = module_load("mods/farming/farming.dylib")
|
||
let init = m.symbol("mod_init") as fn(ModApi) -> void
|
||
```
|
||
|
||
#### G-21 · Compression
|
||
|
||
**Luanti needs.** zstd for map format ≥29, zlib below it, and both directions.
|
||
|
||
**Ludic has.** `runtime/native/inflate.ludic` — **decoder only** (verified: the
|
||
file implements `z_start`, `z_bits`, `z_table_build`, `z_decode`, `z_stored`;
|
||
there is no encoder). PNGs load; nothing can be written.
|
||
|
||
**Proposed.** Write `deflate.ludic` (fixed-Huffman + LZ77 with a hash chain is
|
||
~300 lines and enough for map storage), and FFI to `libzstd` via `extern fn` for
|
||
the real thing. Both consistent with existing project practice.
|
||
|
||
### Tier 3 — The voxel engine
|
||
|
||
#### G-22 · A voxel chunk primitive
|
||
|
||
This is the heart. **Nodes must not be ECS entities** — 36 million entities is
|
||
absurd, and Ludic's `entity` is an i32 index into parallel arrays. Voxels want
|
||
the opposite layout: dense, implicit position, no id.
|
||
|
||
**Proposed** — make chunked volumes a first-class construct, the way `component`
|
||
is:
|
||
|
||
```ludic
|
||
volume Map of MapNode chunk 16 { # 16³ chunks of MapNode
|
||
origin v3i # chunk coordinate
|
||
meta { generated: bool, timestamp: u32, lighting_complete: u16 }
|
||
}
|
||
|
||
# indexing is bounds-safe and computes chunk + offset in one shot
|
||
let n = Map[pos]
|
||
Map[pos] = MapNode { param0 = C_STONE }
|
||
|
||
# an explicit borrowed working area = Luanti's MMVManip / VoxelManip,
|
||
# which is how mapgen and bulk edits avoid touching the live map
|
||
manip vm = Map.borrow(pmin, pmax)
|
||
for p in vm.area { if vm[p].param0 == C_AIR { vm[p] = stone } }
|
||
vm.commit() # write back + relight + remesh
|
||
```
|
||
|
||
Mapped to Luanti: `Map` ≈ `ServerMap`, `chunk` ≈ `MapBlock`, `manip` ≈
|
||
`MMVManip`, `vm.area` ≈ `VoxelArea`, and `commit()` ≈
|
||
`blit_back + update_liquids + calc_lighting`.
|
||
|
||
**Work.** L. Chunk table (hash map keyed on `v3i` — needs G-11), an allocator
|
||
for chunk payloads (G-08), the index arithmetic, and a loaded/unloaded state
|
||
machine. But note: this is *exactly* the kind of thing a domain-specific game
|
||
language should have, and no general-purpose language offers it.
|
||
|
||
#### G-23 · Lighting & meshing
|
||
|
||
**Lighting.** Luanti propagates two banks (day/night) via BFS flood fill across
|
||
block boundaries (`voxelalgorithms.cpp`, 1,309 LOC), with the 12-bit
|
||
`lighting_complete` mask tracking which faces are still provisional.
|
||
|
||
**Meshing.** `content_mapblock.cpp` (1,870 LOC) emits geometry per drawtype;
|
||
`mapblock_mesh.cpp` builds the buffers; `mesh_generator_thread.cpp` does it off
|
||
the main thread.
|
||
|
||
**Proposed.** Both stay *library* code written in Ludic — but they need G-01
|
||
(vertex arrays), G-04 (float vertex data for the GPU), G-17 (mesh threads), and
|
||
G-28 (somewhere to send the buffers). Language-level help worth adding:
|
||
|
||
```ludic
|
||
# a declarative face-culling rule set, instead of 19 hand-written branches
|
||
drawtype Normal {
|
||
cull when neighbor.solid
|
||
faces cube tiles [top, bottom, left, right, front, back]
|
||
}
|
||
drawtype Plantlike { faces cross scale visual_scale cull never }
|
||
```
|
||
|
||
#### G-24 · Collision, raycast, AABB
|
||
|
||
Swept AABB against the node grid + other objects, with `stepheight`; a voxel
|
||
DDA raycast for pointing; A* over the node grid for mob pathing (1,419 LOC).
|
||
|
||
All expressible in Ludic **once G-04 (wide numbers) and G-07 (`sqrt`) exist.**
|
||
No new language feature needed — this is library work, ~2,000 lines.
|
||
|
||
### Tier 4 — Extensibility
|
||
|
||
#### G-25 · The mod model — the hard architectural call
|
||
|
||
**The problem.** Luanti's entire value proposition is that a server operator
|
||
drops a folder into `mods/` and restarts. 321 `core.*` functions exist to serve
|
||
that. Ludic is AOT-compiled, and the project constraint is explicit: **no VM,
|
||
no interpreter, no scripting language, ever.**
|
||
|
||
These are not reconcilable by adding Lua. They *are* reconcilable, and the
|
||
answer is already half-built in the repo — `ludicc lib.ludic --shared -o
|
||
lib.dylib` and `extern fn … = "symbol"`.
|
||
|
||
**Proposed: compiled mods behind a stable C-ABI interface.**
|
||
|
||
```ludic
|
||
# mods/farming/farming.ludic
|
||
mod Farming {
|
||
version 1
|
||
depends ["core"]
|
||
|
||
on init(api: ModApi) {
|
||
api.register_node("farming:wheat_1", NodeDef {
|
||
drawtype = DrawType.Plantlike,
|
||
tiles = ["farming_wheat_1.png"],
|
||
groups = [("snappy", 3), ("flammable", 2)],
|
||
walkable = false,
|
||
buildable_to = true,
|
||
})
|
||
|
||
api.register_abm(Abm {
|
||
nodes = ["farming:wheat_1"],
|
||
interval = 20.0, chance = 20,
|
||
action = grow_wheat,
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
Built with `ludicc mods/farming/farming.ludic --shared -o mods/farming/farming.dylib`,
|
||
loaded at startup via G-20, and talking to the engine only through a versioned
|
||
`ModApi` vtable.
|
||
|
||
**What you gain over Lua:** mods run at native speed (Luanti's ABMs and
|
||
`on_generated` handlers are its top profiler entries, and they are all Lua);
|
||
mod errors are compile-time; the ECS is available to mods directly.
|
||
|
||
**What you lose, and must decide about:**
|
||
- **No hot reload** without process restart (mitigable: reload the dylib).
|
||
- **No sandbox.** Lua's `s_security.cpp` confines mods to their own directory;
|
||
a native dylib can do anything. Options: (a) accept it, like Factorio pre-2.0
|
||
or any C++ plugin system; (b) run mods in a subprocess with an IPC boundary;
|
||
(c) restrict the `ModApi` surface and ship signed mods.
|
||
- **Distribution.** Mods become per-platform binaries, or ship as source and
|
||
compile on first load (`ludicc` is 2,508 lines — shipping it *is* viable).
|
||
|
||
**My recommendation:** ship-as-source + compile-on-load. It keeps the "drop a
|
||
folder in" workflow, keeps everything in one language, needs no VM, and turns
|
||
`ludicc`'s small size into a genuine architectural advantage.
|
||
|
||
#### G-26 · Definition tables & registries
|
||
|
||
Luanti registers definitions from Lua tables at runtime. Ludic has no table
|
||
literal, no runtime registry, and no way to name 60,000 content ids.
|
||
|
||
**Proposed** — a declarative `def` construct that is data at compile time *and*
|
||
registrable at runtime:
|
||
|
||
```ludic
|
||
def node Stone {
|
||
name = "default:stone"
|
||
drawtype = DrawType.Normal
|
||
tiles = ["default_stone.png"]
|
||
groups = [("cracky", 3)]
|
||
is_ground_content = true
|
||
sounds = SoundSet.Stone
|
||
drop = "default:cobble"
|
||
}
|
||
```
|
||
|
||
The compiler assigns content ids, emits the table as static data, and the
|
||
registry maps `str → u16` at load (G-11). Mod-supplied defs go through the same
|
||
path at runtime.
|
||
|
||
#### G-27 · Metadata
|
||
|
||
`NodeMetaRef` / `ItemStackMetaRef` / `PlayerMetaRef`: string→string stores
|
||
attached to positions, stacks, and players, with an "inventory" attached to node
|
||
meta. Needs G-03 and G-11; then it is straightforward library code.
|
||
|
||
### Tier 5 — Graphics & UI
|
||
|
||
#### G-28 · The GPU
|
||
|
||
Luanti bundles an **87,554-line Irrlicht fork** to get: OpenGL 3 / GLES2 /
|
||
WebGL contexts, vertex + index buffers, texture management with mipmaps and
|
||
atlases, a material/shader system, scene graph, frustum culling, and mesh
|
||
loaders (B3D, OBJ, glTF). On top of that sit 20 GLSL programs.
|
||
|
||
Ludic has a 320×240 software framebuffer presented through
|
||
`runtime/native/cocoa.ll` (327 lines of hand-written LLVM IR calling
|
||
`objc_msgSend`).
|
||
|
||
**This is the largest single gap in the document.** Options:
|
||
|
||
| Option | Cost | Fit with constraints |
|
||
|---|---|---|
|
||
| Software rasteriser in Ludic | L | ✅ perfect — but ~5 fps at 720p for voxels |
|
||
| Bind OpenGL/Metal via `extern fn` | M | ✅ same posture as zlib FFI; **recommended** |
|
||
| Hand-written IR per platform, like `cocoa.ll` | XL | ✅ but repeated per API |
|
||
| Write a GPU driver | ✗ | not serious |
|
||
|
||
**Recommended:** treat the GPU exactly as the project already treats the OS —
|
||
an ABI to call, not a C program to compile. `extern function glDrawElements(…) =
|
||
"glDrawElements"`, with a thin `runtime/native/gfx3d.ludic` over it. This is
|
||
consistent with the existing rule ("C libraries are used via FFI") and needs
|
||
**no compiler change beyond G-04's `f32`.**
|
||
|
||
#### G-29 · 3D math types
|
||
|
||
```ludic
|
||
struct v3f { x: f32 = 0, y: f32 = 0, z: f32 = 0 }
|
||
struct m4 { m: [16]f32 }
|
||
struct aabb { min: v3f, max: v3f }
|
||
|
||
function dot(a: v3f, b: v3f) -> f32
|
||
function cross(a: v3f, b: v3f) -> v3f
|
||
function normalize(v: v3f) -> v3f
|
||
function look_at(eye: v3f, at: v3f, up: v3f) -> m4
|
||
function perspective(fovy: f32, aspect: f32, znear: f32, zfar: f32) -> m4
|
||
```
|
||
|
||
Worth considering: **operator overloading for vector types only** — Ludic
|
||
currently forbids overloading entirely, and `a + b` on vectors is the one place
|
||
where the ban costs real readability. A narrow, built-in-types-only exception is
|
||
defensible.
|
||
|
||
#### G-30 · UI at formspec scale
|
||
|
||
Ludic's `ui` block is architecturally right and 8 widgets deep. Formspec is 64
|
||
elements. The gap that matters most:
|
||
|
||
| Missing | Why it blocks Luanti |
|
||
|---|---|
|
||
| `field` / `textarea` / `pwdfield` | no text input at all — no chat, no sign editing |
|
||
| `list` (inventory slots) | the core interaction of the game |
|
||
| `scroll_container` / `scrollbar` | any inventory bigger than a screen |
|
||
| `table` / `textlist` | server list, mod manager |
|
||
| `dropdown`, `checkbox`, `tabheader` | settings |
|
||
| `model` | 3D item preview |
|
||
| `style[]` / `style_type[]` | theming |
|
||
| mouse input | **Ludic's UI is keyboard-only today** (`ui_tick(key())`) |
|
||
| `hypertext` markup | formatted text |
|
||
|
||
### Tier 6 — Audio & i18n
|
||
|
||
#### G-31 · Audio
|
||
|
||
Nothing exists. Needed: a mixer, Ogg Vorbis decode (or ship a simpler codec),
|
||
3D panning + attenuation, and an audio callback thread (G-17). Consistent with
|
||
project practice: decode in Ludic (as PNG and TrueType already are), bind
|
||
CoreAudio/ALSA/WASAPI via `extern fn`.
|
||
|
||
```ludic
|
||
let s = sound_load("assets/dig_stone.ogg")
|
||
sound_play(s, SoundSpec { gain = 0.8, pitch = 1.0, pos = p, max_hear = 32.0 })
|
||
```
|
||
|
||
#### G-32 · i18n
|
||
|
||
Luanti: gettext, plural forms (`gettext_plural_form.cpp`), per-server
|
||
translation files (`.tr`), and escape-sequence-based inline translation. Ludic
|
||
has UTF-8 rendering (good) and no translation machinery. Needs G-03 and G-11.
|
||
|
||
### Tier 7 — Persistence & network
|
||
|
||
#### G-33 · Persistence
|
||
|
||
Ludic's `save()`/`load()` writes **the entire ECS world** in one blob. Luanti
|
||
needs per-block, versioned, compressed, incrementally-written storage keyed in
|
||
SQLite, plus player files, auth, and mod storage.
|
||
|
||
```ludic
|
||
serialize MapBlock version 29 {
|
||
u8 version
|
||
u8 flags
|
||
u16 lighting_complete
|
||
u32 timestamp
|
||
map name_id: Map[u16, str]
|
||
zstd { # everything inside is compressed
|
||
array param0: [4096]u16 bigendian
|
||
array param1: [4096]u8
|
||
array param2: [4096]u8
|
||
list metadata
|
||
list timers
|
||
list static_objects
|
||
}
|
||
}
|
||
```
|
||
|
||
A declarative `serialize` block generating both encoder and decoder would be a
|
||
strong Ludic feature — it is exactly the kind of error-prone, symmetric,
|
||
version-tagged code that a game language should generate rather than hand-write.
|
||
|
||
#### G-34 · Networking
|
||
|
||
90 packet types, reliable UDP with split packets and ack windows, and priority
|
||
block streaming. Depends on G-17, G-18, G-21, G-33. The packet definitions
|
||
themselves fall out of `serialize` (G-33) + `union` (G-06):
|
||
|
||
```ludic
|
||
packet ToClient {
|
||
BlockData = 0x20 { pos: v3i, block: MapBlock }
|
||
AddParticle = 0x46 { … }
|
||
ActiveObjectMessages = 0x31 { msgs: []ObjectMsg }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Roadmap
|
||
|
||
Ten milestones. Each one **ends in something runnable** — that is the rule; no
|
||
milestone is "add a type system feature" with nothing to show. Effort estimates
|
||
assume one developer and are deliberately conservative.
|
||
|
||
### M0 — Language core, part 1: aggregates *(≈6 weeks)*
|
||
> **Gates:** G-01 arrays/slices · G-02 structs · G-05 sized ints
|
||
|
||
**Demo:** rewrite `runtime/native/image.ludic`'s PNG decoder to use
|
||
`[]u8` and `struct Pixel` instead of `mem_alloc` + `peek8`. Same output PNG,
|
||
half the lines, bounds-checked.
|
||
|
||
**Why first:** every other item in this document is blocked on it. The existing
|
||
runtime is the perfect proving ground — it is 3,700 lines of exactly the
|
||
hand-rolled pointer arithmetic these features exist to delete.
|
||
|
||
```ludic
|
||
# before (runtime/native/image.ludic today)
|
||
function px(img: pointer, x: int, y: int, w: int) -> int { return peek32(img, (y*w+x)*4) }
|
||
|
||
# after
|
||
struct Rgba { r: u8, g: u8, b: u8, a: u8 }
|
||
function px(img: []Rgba, x: int, y: int, w: int) -> Rgba { return img[y*w + x] }
|
||
```
|
||
|
||
### M1 — Language core, part 2: numbers, strings, errors *(≈6 weeks)*
|
||
> **Gates:** G-03 strings · G-04 `f32`/`fixed64` · G-06 enums/unions · G-07 math · G-09 errors
|
||
|
||
**Demo:** a `noise.ludic` implementing Luanti's `NoiseFractal3D` exactly, and a
|
||
headless program that renders a 512×512 heightmap PNG from it. Compare against
|
||
Luanti's own output for the same seed — **bit-comparable terrain is the
|
||
acceptance test.**
|
||
|
||
This is the milestone that closes the coordinate bug (G-04). Worth writing the
|
||
regression test first:
|
||
|
||
```ludic
|
||
system CoordRange phase Start {
|
||
let far: fixed64 = 310070.0 # would wrap in Q16.16
|
||
assert(flr64(far) == 310070)
|
||
let d = far * far # 9.6e10 — must not wrap
|
||
}
|
||
```
|
||
|
||
### M2 — Platform *(≈5 weeks)*
|
||
> **Gates:** G-17 threads · G-19 time · G-18 sockets · G-20 dynamic loading · G-21 deflate encode
|
||
|
||
**Demo:** a headless Ludic program that spawns 4 worker threads, each generating
|
||
a 16³ noise chunk, deflate-compresses it, writes it to a file, and a second
|
||
process that reads it back — proving the thread pool, the compressor and the
|
||
round-trip.
|
||
|
||
**Note on the new WASM target.** A peer session just landed WebAssembly support
|
||
(`runtime/web/`, `bin/x test` now 64/64), and `@main` is now split into
|
||
`ludic_boot/frame/alive/teardown` so a browser can drive the loop from
|
||
`requestAnimationFrame`. Two consequences for this roadmap: (a) threads on
|
||
wasm32 mean Web Workers + SharedArrayBuffer, not pthreads, so **G-17 needs a
|
||
per-target backend from day one**; (b) `size_t` is `i32` on wasm — new
|
||
intrinsics must go through `ll_size_t()`/`ll_widen()`/`ll_narrow()`, which
|
||
matters for every slice and array intrinsic added in M0.
|
||
|
||
### M3 — The voxel primitive *(≈8 weeks)*
|
||
> **Gates:** G-11 maps · G-12 ECS scale · G-13 attach/detach · G-22 `volume`/`manip`
|
||
|
||
**Demo:** **a flat 3D world you can fly through**, still software-rendered at
|
||
320×240 — a chunked `volume Map of MapNode chunk 16`, chunks generated on the
|
||
worker pool from M2, a free camera, and a simple raycast rasteriser for
|
||
visualisation. Ugly, slow, and unambiguously a voxel engine.
|
||
|
||
```ludic
|
||
volume Map of MapNode chunk 16 { origin v3i meta { generated: bool } }
|
||
|
||
system Generate phase Update {
|
||
for bp in pending_chunks() {
|
||
manip vm = Map.borrow_chunk(bp)
|
||
for p in vm.area {
|
||
let h = floor(noise2d(fixed(p.x) / 64.0, fixed(p.z) / 64.0) * 24.0) + 8
|
||
vm[p] = if p.y <= h { NODE_STONE } else { NODE_AIR }
|
||
}
|
||
vm.commit()
|
||
}
|
||
}
|
||
```
|
||
|
||
### M4 — GPU + 3D *(≈10 weeks)*
|
||
> **Gates:** G-28 GPU FFI · G-29 3D math · G-23 meshing/lighting
|
||
|
||
**Demo:** the M3 world, **textured, lit and running at 60 fps** — greedy or
|
||
per-face meshing on a worker thread, day/night light banks propagated by BFS,
|
||
one shader program, a texture atlas from the existing PNG decoder.
|
||
|
||
This is where the project stops being a 2D game language and becomes capable of
|
||
Luanti's genre. It is also the milestone with the most schedule risk — bind
|
||
OpenGL 3.3 via `extern fn` first (Metal/WebGL later) and resist the temptation
|
||
to write a scene graph.
|
||
|
||
### M5 — Interaction *(≈6 weeks)*
|
||
> **Gates:** G-24 collision/raycast · G-30 UI (input, lists, scroll) · mouse input
|
||
|
||
**Demo:** **you can walk, jump, dig and place.** Swept-AABB player physics with
|
||
`stepheight`, a DDA raycast for pointing, a hotbar, and an inventory screen with
|
||
draggable slots. This is the first milestone that is *playable*.
|
||
|
||
### M6 — Definitions & registries *(≈5 weeks)*
|
||
> **Gates:** G-26 `def` blocks · G-27 metadata · G-16 ABM systems
|
||
|
||
**Demo:** 30 node types and 10 item types declared with `def node` / `def item`,
|
||
groups-based dig times, a chest with node metadata and an inventory, and lava
|
||
that cools into obsidian via an ABM-shaped system. **This is the milestone where
|
||
the language's ECS finally pays off visibly.**
|
||
|
||
### M7 — Persistence *(≈4 weeks)*
|
||
> **Gates:** G-33 `serialize` blocks · SQLite via FFI
|
||
|
||
**Demo:** quit and relaunch; the world, your inventory and your position are
|
||
exactly as you left them. Blocks written incrementally, zstd-compressed, keyed
|
||
by interleaved position — read Luanti's own `map.sqlite` as a compatibility
|
||
stretch goal.
|
||
|
||
### M8 — The mod ABI *(≈8 weeks)*
|
||
> **Gates:** G-25 mod model · G-20 loading · a versioned `ModApi`
|
||
|
||
**Demo:** a `mods/farming/` folder containing only `.ludic` source and textures;
|
||
drop it in, restart, and wheat exists — compiled on first load, cached as a
|
||
dylib. Then delete the folder and it is gone. **This is the milestone that makes
|
||
it Luanti rather than a voxel game.**
|
||
|
||
### M9 — Multiplayer *(≈12 weeks)*
|
||
> **Gates:** G-34 protocol · G-14 multiple worlds · G-18 sockets
|
||
|
||
**Demo:** two clients, one server, on separate machines: block changes,
|
||
movement, chat, and inventory sync. Client and server in one binary via
|
||
`world Server` / `world Client` (G-14) so singleplayer is just a loopback.
|
||
|
||
### Audio & i18n *(fold into M5–M8 opportunistically)*
|
||
> **Gates:** G-31 · G-32
|
||
|
||
### Timeline summary
|
||
|
||
| Milestone | Effort | Cumulative | Ends with |
|
||
|---|---|---|---|
|
||
| M0 aggregates | 6 wk | 6 wk | runtime rewritten on arrays/structs |
|
||
| M1 numbers/strings | 6 wk | 12 wk | Luanti-comparable noise terrain |
|
||
| M2 platform | 5 wk | 17 wk | threaded, compressed chunk I/O |
|
||
| M3 voxel primitive | 8 wk | 25 wk | flyable 3D voxel world |
|
||
| M4 GPU | 10 wk | 35 wk | textured, lit, 60 fps |
|
||
| M5 interaction | 6 wk | 41 wk | **playable: walk, dig, place** |
|
||
| M6 definitions | 5 wk | 46 wk | modded content, ABMs |
|
||
| M7 persistence | 4 wk | 50 wk | worlds that survive restart |
|
||
| M8 mod ABI | 8 wk | 58 wk | **drop-in mods** |
|
||
| M9 multiplayer | 12 wk | 70 wk | two clients, one server |
|
||
|
||
≈**70 developer-weeks** to a Luanti-class engine written in Ludic. For scale:
|
||
Luanti itself is 362,258 lines accumulated since 2010.
|
||
|
||
---
|
||
|
||
## 7. Decisions I need from you
|
||
|
||
These change the shape of the work and are yours to make, not mine. My
|
||
recommendation is given first in each case.
|
||
|
||
1. **Numerics: `fixed64` *and* `f32`, or just `f32`?**
|
||
*Recommendation: both.* `fixed64` for gameplay/physics (deterministic
|
||
lockstep multiplayer is a real advantage, and Luanti's float physics is a
|
||
known source of desync); `f32` for noise and GPU vertex data, where it is
|
||
non-negotiable.
|
||
|
||
2. **Mod distribution: source-and-compile-on-load, or prebuilt binaries?**
|
||
*Recommendation: source.* `ludicc` is 2,508 lines — shipping the compiler
|
||
with the game preserves the "drop a folder in" workflow that is Luanti's
|
||
entire ecosystem, keeps one language end-to-end, and needs no VM.
|
||
|
||
3. **Mod sandboxing: accept native trust, or subprocess isolation?**
|
||
*Recommendation: accept it for M8, revisit before any public server.*
|
||
Luanti's `s_security.cpp` is 1,253 lines of Lua-specific confinement that has
|
||
no native analogue; subprocess + IPC is the only real answer and it is a
|
||
quarter of work on its own.
|
||
|
||
4. **GPU: FFI to OpenGL, or hand-written IR per platform like `cocoa.ll`?**
|
||
*Recommendation: FFI.* It matches the project's existing posture toward
|
||
libraries, and `cocoa.ll` is 327 lines for *five* window functions — OpenGL
|
||
has hundreds of entry points.
|
||
|
||
5. **Compatibility: read Luanti's `map.sqlite` and speak protocol 37+, or a
|
||
clean-room format?** *Recommendation: clean room, with a one-way importer.*
|
||
Wire compatibility means implementing 90 packet types before anything is
|
||
playable; it inverts the whole milestone order.
|
||
|
||
6. **Scope: the full engine, or a Luanti-shaped game?** Worth being honest with
|
||
yourself here. M0–M6 (≈46 weeks) gets a genuinely good single-player voxel
|
||
game and, more importantly, **makes Ludic a language that could plausibly
|
||
build one.** M8–M9 are what make it *Luanti*, and they are half the budget.
|
||
|
||
---
|
||
|
||
## 8. Appendix A — target state
|
||
|
||
What `examples/voxel.ludic` would look like once M0–M6 land. This is the
|
||
document's thesis in one file: nothing here is expressible today, and all of it
|
||
is ordinary Ludic in shape.
|
||
|
||
```ludic
|
||
game Voxel {
|
||
import "voxel/nodes.ludic"
|
||
import "voxel/worldgen.ludic"
|
||
|
||
# ---- data ---------------------------------------------------------------
|
||
struct MapNode { param0: u16 = 0, param1: u8 = 0, param2: u8 = 0 }
|
||
struct v3i { x: int = 0, y: int = 0, z: int = 0 }
|
||
struct v3f { x: f32 = 0, y: f32 = 0, z: f32 = 0 }
|
||
|
||
volume Map of MapNode chunk 16 {
|
||
origin v3i
|
||
meta { generated: bool = false, timestamp: u32 = 0 }
|
||
}
|
||
|
||
component Body { pos: v3f, vel: v3f, aabb: aabb, on_ground: bool = false }
|
||
component Look { yaw: f32 = 0, pitch: f32 = 0 }
|
||
component Health { hp: int = 20, max: int = 20 }
|
||
component Inv { slots: [32]ItemStack }
|
||
|
||
archetype Player { Body, Look, Health, Inv }
|
||
archetype Mob { Body, Look, Health }
|
||
|
||
# ---- content ------------------------------------------------------------
|
||
def node Stone {
|
||
name = "voxel:stone" drawtype = DrawType.Normal
|
||
tiles = ["stone.png"] groups = [("cracky", 3)]
|
||
is_ground_content = true drop = "voxel:cobble"
|
||
}
|
||
def node Water {
|
||
name = "voxel:water" drawtype = DrawType.Liquid
|
||
tiles = ["water.png"] liquid = Liquid.Source { alt_flowing = "voxel:water_flowing" }
|
||
walkable = false light_propagates = true
|
||
}
|
||
|
||
# ---- generation, off the main thread ------------------------------------
|
||
thread pool emerge count 4 {
|
||
job Generate(bp: v3i) -> Chunk {
|
||
manip vm = Map.stage(bp)
|
||
for p in vm.area {
|
||
let n = noise2d(p.x as f32 / 64.0, p.z as f32 / 64.0, seed())
|
||
let h = floor(n * 24.0) + 8
|
||
vm[p] = if p.y > h { NODE_AIR }
|
||
else if p.y == h { NODE_GRASS }
|
||
else { NODE_STONE }
|
||
}
|
||
return vm.finish()
|
||
}
|
||
}
|
||
|
||
system Emerge phase Update {
|
||
for bp in Map.wanted(view_radius = 10) { emerge.submit(Generate(bp)) }
|
||
while let c = emerge.poll() { Map.install(c) relight(c) remesh(c) }
|
||
}
|
||
|
||
# ---- simulation: an ABM as a first-class system -------------------------
|
||
system LavaCooling phase Update
|
||
every 10.0s chance 1 in 50
|
||
nodes [NODE_LAVA_SOURCE] near [NODE_WATER_SOURCE, NODE_WATER_FLOWING]
|
||
{ Map[self_pos()] = NODE_OBSIDIAN }
|
||
|
||
# ---- physics ------------------------------------------------------------
|
||
system Physics phase FixedUpdate
|
||
query (b) [Body]
|
||
{
|
||
b.vel.y = b.vel.y - 9.81 * dt()
|
||
let r = collide_swept(Map, b.aabb, b.pos, b.vel * dt(), stepheight = 0.6)
|
||
b.pos = r.pos
|
||
b.on_ground = r.hit_floor
|
||
if r.hit_floor { b.vel.y = 0.0 }
|
||
}
|
||
|
||
# ---- interaction --------------------------------------------------------
|
||
system Dig phase Input
|
||
query (b, l) [Body, Look, {Player}]
|
||
{
|
||
when mouse_pressed(MOUSE_LEFT) {
|
||
match raycast(Map, eye(b), dir(l), 5.0) {
|
||
Pointed.Node(under, _) => {
|
||
let def = node_def(Map[under].param0)
|
||
give(self(), def.drop)
|
||
Map[under] = NODE_AIR
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
# ---- render -------------------------------------------------------------
|
||
scene InGame start {
|
||
layer World {
|
||
system DrawWorld phase Render {
|
||
gfx_begin(camera_of(local_player()))
|
||
for c in Map.visible(frustum()) { gfx_draw_mesh(c.mesh) }
|
||
gfx_end()
|
||
}
|
||
}
|
||
layer Hud {
|
||
system DrawHud phase Render { ui_render() }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Read that against LANGUAGE.md.** The `system`/`query`/`phase`/`scene`/`layer`
|
||
/`archetype`/`match` skeleton is *unchanged* — every line of new capability is
|
||
either a type (`struct`, `f32`, `[]T`, `enum`) or one of four new constructs
|
||
(`volume`, `manip`, `thread pool`, `def`). That is the encouraging finding of
|
||
this whole exercise: **Ludic's programming model is already the right one for
|
||
this game. What is missing is underneath it, not around it.**
|
||
|
||
---
|
||
|
||
## 9. Appendix B — what Ludic already has that Luanti had to build
|
||
|
||
Not everything is a deficit. Ludic starts with several things Luanti spent years
|
||
acquiring, and they should be counted:
|
||
|
||
| Ludic has | Luanti equivalent | Cost Luanti paid |
|
||
|---|---|---|
|
||
| ECS in the language (`component`/`query`/`system`/`phase`) | hand-rolled `ActiveObjectMgr` + ad-hoc containers | scattered across `serverenvironment.cpp` (2,091) + `activeobjectmgr` |
|
||
| `scene`/`layer` state machine | mode flags and `if` ladders in `game.cpp` | part of 3,815 LOC |
|
||
| `match`/`machine`/`become` | switch chains | — |
|
||
| Retained `ui` tree as data | formspec **string DSL** parsed at runtime | `guiFormSpecMenu.cpp`, **5,610 LOC** |
|
||
| TrueType engine written in-language | FreeType + `CGUITTFont.cpp` (1,003) | external dep |
|
||
| PNG + DEFLATE decode in-language | libpng + zlib | external deps |
|
||
| Deterministic RNG in the language | `PcgRandom`/`PseudoRandom` exposed to Lua | — |
|
||
| Compile-time type checking of game logic | Lua: runtime errors on a live server | the entire class of mod crashes |
|
||
| One language for engine *and* content | C++ engine + Lua mods + a 1,253-line sandbox | the whole `src/script/` tree, **34,240 LOC** |
|
||
|
||
That last row is the strategic one. **A third of Luanti's engine — 34,240 lines
|
||
of `src/script/` plus 22,634 lines of `builtin/` Lua — exists solely to bridge
|
||
C++ and Lua.** A Ludic implementation with a compiled mod ABI (G-25) does not
|
||
need that bridge at all. Roughly 57,000 lines of Luanti are a tax that Ludic
|
||
would simply not pay.
|
||
|
||
---
|
||
|
||
## 10. Verdict
|
||
|
||
**Can Ludic implement Luanti today?** No — and not because of anything about
|
||
games. It is missing arrays, strings, structs, floats, threads and sockets. Any
|
||
language missing those cannot implement any large program.
|
||
|
||
**Is Ludic's design wrong for Luanti?** No — and this is the more interesting
|
||
answer. The ECS-in-the-language model, phases, scenes, archetypes and `match`
|
||
are a *better* fit for a voxel sandbox than C++-plus-Lua is. The `system … every
|
||
10.0s chance 1 in 50 nodes […]` form (G-16) compiles to something Luanti's ABM
|
||
implementation cannot achieve, because Luanti's predicates arrive as strings at
|
||
runtime and Ludic's arrive at compile time.
|
||
|
||
**What is the actual shortest path?** M0 and M1. Arrays, structs, strings,
|
||
floats. Twelve weeks that convert Ludic from a language that can express a
|
||
2D JRPG into one that can express an engine — after which every remaining gap in
|
||
this document is ordinary library work in Ludic, plus one hard call about mods
|
||
(G-25) and one large push on the GPU (G-28).
|
||
|
||
---
|
||
|
||
*Generated from `luanti-org/luanti@main` (362,258 LOC surveyed) against
|
||
`ludicc` at `compiler/{ludicc,native,driver}.c` and `runtime/native/*.ludic`.
|
||
Every Ludic limitation cited was verified by reading the compiler or by
|
||
compiling a probe program, not inferred from documentation.*
|