7.4 KiB
ludic.base
The vocabulary a game made of mechanic packages shares. A mechanic (fishing, needs, a shop, the
weather) depends on ludic.base and on nothing else - never on another mechanic. The game is the
only code that knows two mechanics, and it wires them together.
(ludic.core is a different, older package: the engine's canonical ECS components, used by the
examples under examples/library/.)
import "ludic.base"
The rules
- A mechanic uses only this. Its module line says so -
module ludic_clock uses ludic_base- and the compiler refuses a reference into any other module, a package's included. Its tests are one program:
ludic.base, the mechanic, and a fake for each port.
- and the compiler refuses a reference into any other module, a package's included. Its tests are one program:
- Ports for questions. What a mechanic needs to ASK the world is a
portit owns (export port FishingWorld { is_water: fn(float, float) -> bool }), in primitive andludic.basetypes only. The game binds it once, as a declaration (bind FishingWorld { is_water: fn lake }), and the compiler refuses a program that uses a port with a required member and never binds it. A member with a default may be left out. - Queues for facts. What HAPPENED is pushed onto a
Queue<T>the mechanic owns (caught) and drained by the game in a later phase. A mechanic never acts on another's behalf. - Verbs for changes. A mechanic's data is its
state(ToyFishingState), handed to its functions as a parameter, and it changes only through its exported functions (fishing_cast,pack_add). Nothing writes another module's state; a function value (fn fishing_tick) is called with its states supplied, so a system never passes them itself. - Phases for order. A system names its phase; within a phase the game's list is the order. No system says "after fishing".
- Its own save section. Each system saves under its own key with its own version and
migrates its own old versions in
load. A missing section is a reset. - Its own dice. A mechanic is handed an
Rng, never draws fromRandom.*, so moving it cannot shift anyone else's rolls.
The API
Tick { dt, frame, hours }, tick_new(dt, frame, hours) |
what a system's tick receives |
PH_INPUT, PH_SENSE, PH_SIMULATE, PH_RESOLVE, PH_COMMIT, PH_PRESENT, PH_COUNT |
the phases, in the order they run |
Queue<T>, queue_new<T>(name), q_push(q, v), q_drain(q) -> []T, q_len(q), q_clear(q) |
facts, first in first out (queue_new, not q_new: that name is render3d's quaternion) |
core_undrained() -> []string |
the names of queues still holding facts; ask it at the end of a frame |
Rng, rng_new(seed), rng_seed(r, seed), rng_next(r), rng_float(r), rng_between(r, lo, hi), rng_span(r, lo, hi) |
a xorshift32 stream of its own (rng_between is inclusive; rng_range is taken by Random.range) |
SaveNode { found, version, data }, save_tree(), save_section(root, key, version, v), load_section(root, key) -> SaveNode, save_encode, save_decode |
the save tree: {"fishing": {"v": 3, ...}}; v is reserved in a section, and a non-object value rides under data |
sv_int, sv_float, sv_bool, sv_str (key, fallback), sv_ints |
typed reads with a fallback; a float is kept as thousandths |
sv_put_int, sv_put_float, sv_put_bool, sv_put_str, sv_put_ints |
the matching writes |
System { key, phase, version, init, reset, tick, save, load }, system_new(key, phase) |
a system; a null function is a verb it does not have |
core_add(s), core_clear(), core_count() |
the game's system list (a key may appear once) |
def Systems key { ... }, SYS_<KEY>, SYS_COUNT |
a system declared from any module; the list starts from these |
core_init_all(), core_reset_all(), core_tick_all(t), core_save_all() -> Val, core_load_all(v) |
the runner: in the order added, tick phase by phase |
A toy mechanic
module toy_fishing uses ludic_base
numbers float
import "ludic.base"
export property Caught { species: int = 0, weight: float = 0.0 }
export port FishingWorld { is_water: fn(float, float) -> bool } # its port
export state ToyFishingState {
dice: Rng = null
casts: int = 0
caught: Queue<Caught> = null # its facts
}
export function fishing_cast(toy_fishing_st: mut ToyFishingState, x: float, z: float) -> bool { # its verb
if not FishingWorld.is_water(x, z) { return false }
toy_fishing_st.casts += 1
return true
}
function fishing_reset(base_st: mut BaseState, toy_fishing_st: mut ToyFishingState) -> void {
toy_fishing_st.casts = 0
toy_fishing_st.dice = rng_new(7)
toy_fishing_st.caught = queue_new(base_st, "fishing.caught")
}
function fishing_tick(base_st: mut BaseState, toy_fishing_st: mut ToyFishingState, t: Tick) -> void {
while toy_fishing_st.casts > 0 {
let c = new Caught
c.species = rng_between(toy_fishing_st.dice, 0, 2)
c.weight = 0.5 + rng_float(toy_fishing_st.dice)
q_push(base_st, toy_fishing_st.caught, c)
toy_fishing_st.casts -= 1
}
}
export function fishing_system() -> System {
let s = system_new("fishing", PH_SIMULATE)
s.reset = fn fishing_reset
s.tick = fn fishing_tick
return s
}
A game wiring two of them
toy_pack is the same shape: verbs pack_add(kind, n) / pack_count(kind) and a system in
PH_COMMIT that saves its counts. Neither knows the other; the game binds fishing's port, writes
the route, and orders the three.
import "toys/fishing"
import "toys/pack"
import "ludic.base"
program Game {
numbers float
function lake(x: float, z: float) -> bool { return x < 100.0 }
bind FishingWorld { is_water: fn lake }
# the route: a landed fish goes into the pack
function route_fishing_pack(base_st: mut BaseState, fishing: ToyFishingState, pack: mut ToyPackState, t: Tick) -> void {
let fish = q_drain(base_st, fishing.caught)
for i in 0 .. len(fish) { pack_add(pack, fish[i].species, 1) }
}
function game_start(base_st: mut BaseState) -> void {
core_add(base_st, fishing_system())
let r = system_new("route.fishing_pack", PH_RESOLVE)
r.tick = fn route_fishing_pack # a fn(Tick) -> void: its states are supplied
core_add(base_st, r)
core_add(base_st, pack_system())
core_reset_all(base_st)
}
}
Both are compiled and run by tests/route_test.ludic (the mechanics are tests/toys/).
Tests
Each piece has a program under tests/, and ludic test runs them all, every test block in a
process of its own:
ludic test packages/ludic.base
queue_test, rng_test, save_test, system_test, registry_test and route_test. They were
written before a generic call inside a test body resolved and before each test had a fresh
state, so the queue cases are functions a test calls and the runner's cases start with
core_clear(); neither is needed any more.
Declared systems
Systemsis anopen registry, so a system can be declared instead of added:def Systems fishing { phase: PH_SIMULATE, tick: fn fishing_tick }from any module. The runner starts from the declared systems - the order the compiler gives an open registry: ludic.base's own (none), then each other module's by module name, each module's in the order it is read - andcore_addappends after them. A game that wants to decide the order itself writes thedefs in its own files, or keeps callingcore_addin one place.