Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling, and docs. Phase 1 of the syntax-redesign cohesion pass has landed: edge-system fix, signature-query, when-alias, and the documentation truth-pass. Suite green (14/14), C-free bootstrap fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
62 KiB
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 build/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 MapNodes: 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.
// 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:
// 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 EmergeThreads — 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.
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:
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, …):
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:
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.
# 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
fn 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":
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 }
fn index(p: v3i) -> int { return (p.z * 16 + p.y) * 16 + p.x }
fn 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:
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.
# `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:
%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):
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).let px: fixed64 = 310070.5 # ±2.1e9 range, 2⁻³² precisionf32/f64— required anyway for mapgen noise (NoiseParamsis all floats and the noise functions are transcendental), and for GPU interop where vertex data must be IEEE floats.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 ints would be 12 bytes —
3× the memory, 441 MB instead of 147 MB for a 10-block view radius.
Proposed.
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 NodeDrawTypes, 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.
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).
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:
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:
fn 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.
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:
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:
- Configurable + dynamic capacity.
game Foo { entities 65536 }for the static bound, and a growth path: component arrays become heap[]Cmpthat double. - 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.
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.
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.
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:
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.
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.
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.
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.
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:
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:
# 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.
# 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.cppconfines 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 theModApisurface and ship signed mods. - Distribution. Mods become per-platform binaries, or ship as source and
compile on first load (
ludiccis 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:
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 fn 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
struct v3f { x: f32 = 0, y: f32 = 0, z: f32 = 0 }
struct m4 { m: [16]f32 }
struct aabb { min: v3f, max: v3f }
fn dot(a: v3f, b: v3f) -> f32
fn cross(a: v3f, b: v3f) -> v3f
fn normalize(v: v3f) -> v3f
fn look_at(eye: v3f, at: v3f, up: v3f) -> m4
fn 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.
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.
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):
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.
# before (runtime/native/image.ludic today)
fn px(img: ptr, 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 }
fn 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:
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/, test.sh 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.
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 = flr(noise2d(fx(p.x) / 64.0, fx(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
defblocks · 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
serializeblocks · 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.
-
Numerics:
fixed64andf32, or justf32? Recommendation: both.fixed64for gameplay/physics (deterministic lockstep multiplayer is a real advantage, and Luanti's float physics is a known source of desync);f32for noise and GPU vertex data, where it is non-negotiable. -
Mod distribution: source-and-compile-on-load, or prebuilt binaries? Recommendation: source.
ludiccis 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. -
Mod sandboxing: accept native trust, or subprocess isolation? Recommendation: accept it for M8, revisit before any public server. Luanti's
s_security.cppis 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. -
GPU: FFI to OpenGL, or hand-written IR per platform like
cocoa.ll? Recommendation: FFI. It matches the project's existing posture toward libraries, andcocoa.llis 327 lines for five window functions — OpenGL has hundreds of entry points. -
Compatibility: read Luanti's
map.sqliteand 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. -
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.
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 = flr(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.