Proof-of-concept for the gametok architecture: a runner binary that
dynamically loads game .so files via dlopen, runs each for a fixed
duration with cooperative shutdown, then unloads and moves to the next.
Adding a new gameN.so to target/debug/ works without recompiling
the runner.
Each game is a hecs ECS mini-game written in pure Homun (.hom), compiled
to a Rust cdylib. Per-game thread_local! World gives full isolation —
game1 and game2 share zero state.
hecs_demo/
├── Cargo.toml [workspace] members = ["game1", "game2", "runner"]
├── game1/ cdylib — linear motion, 3 balls
│ ├── Cargo.toml
│ ├── build.rs homunc src/main.hom → $OUT_DIR/main.rs (+ patch #![ → #[)
│ └── src/
│ ├── main.hom Homun: components + main_tick(dt) system
│ ├── lib.rs FFI trampoline: extern "C" fn tick(dt)
│ └── hom_hecs/
│ └── mod.rs Homun-friendly free-fn adapter over hecs (thread_local World)
├── game2/ cdylib — orbital motion, 4 entities (same shape as game1)
│ └── ...
└── runner/ bin — scans .so, cycles, cooperative shutdown
├── Cargo.toml
├── build.rs
└── src/
├── main.hom Homun: scan_so + run_game loop
├── main.rs FFI-style include trampoline
└── hom_runner/
└── mod.rs libloading + thread + AtomicBool + scan_so
Workspace builds inside this directory:
cd hecs_demo
cargo build
cargo run -p runnerExpected output:
[runner] cycling to .../target/debug/libgame1.so
[runner] loading ...
[game1] spawned 3 balls
[game1] pos=(0.016,0.008)
... (~5 seconds @ 60fps × 3 balls)
[runner] unloaded ...
[runner] cycling to .../target/debug/libgame2.so
[game2] spawned 4 orbiters
[game2] orb=(0.99964,0.024)
... (~5 seconds × 4 orbiters)
[runner] unloaded ...
[runner] one pass complete
# 1. start with only game1
mv target/debug/libgame2.so /tmp/holdout
cargo run -p runner # cycles only game1
# 2. drop game2 back, run again — same binary picks it up
mv /tmp/holdout target/debug/libgame2.so
cargo run -p runner # cycles BOTH game1 and game2The runner binary is unchanged between runs. Adding a game3 cdylib that
exports extern "C" fn tick(dt: f32) would work the same way.
Each game .so exports exactly one symbol:
#[no_mangle]
pub extern "C" fn tick(dt: f32);The runner doesn't know component types, systems, or anything game-specific
— it just calls tick(0.016) ~60 times per second.
hom_hecs/mod.rs keeps the hecs World in a thread_local! static WORLD: RefCell<World>.
Each cdylib has its OWN compiled instance of this thread-local; even on the
same OS thread, game1's WORLD is a separate static from game2's WORLD.
When the runner spawns a worker thread for a new game, that thread's first
call to tick(dt) lazily initializes its WORLD. When the worker thread
exits (cooperative stop), thread-local destructors fire and the World is
dropped while the cdylib is still loaded — no use-after-unmap.
let lib = unsafe { Library::new(path)? };
let tick: unsafe extern "C" fn(f32) = unsafe { *lib.get(b"tick\0")? };
// raw fn pointer is Copy + Send
let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn({ let stop = stop.clone(); move || {
while !stop.load(Ordering::Relaxed) {
unsafe { tick(0.016) };
thread::sleep(Duration::from_millis(16));
}
// natural return → thread_local World destructor runs (lib still alive)
}});
thread::sleep(Duration::from_millis(duration_ms as u64));
stop.store(true, Ordering::Relaxed);
handle.join().ok(); // wait for worker exit + destructors
drop(lib); // dlclose AFTER thread is gone — safeRust deliberately does not expose pthread_cancel because force-killing
a thread skips all destructors and leaves locks/allocator state corrupt.
Cooperative shutdown is the standard idiom and costs at most one frame
(~16 ms) of latency per game switch.
homunc emits generated .rs with #![allow(...)] (inner attrs) at the
top — correct for standalone homunc x.hom -o x.rs && rustc x.rs use.
For include!() inclusion in a cdylib, those inner attrs land in a
position Rust rejects, so build.rs patches them to outer #[...] after
generation. Each lib.rs / main.rs then declares its OWN crate-level
#![allow(...)] so the included items still get the warning suppression.
- Create
game3/mirroring game1's structure. - Add
"game3"toCargo.tomlworkspace members. - Write
game3/src/main.homwith components + amain_tick(dt)function. - Copy
game3/src/lib.rsandgame3/src/hom_hecs/from game1 (no edits). cargo buildproducestarget/debug/libgame3.so.- The runner picks it up on next launch — no code changes.
This is a transport-layer POC. Not yet covered:
- Hot-reload (file-system watcher → live reload while runner is running)
- Save/restore game state across swipes
- Render integration (
tick(dt)only prints; real engine would draw) - Input passing (no input plumbed through the FFI yet)
- Multi-thread per game (each game is single-threaded ECS)
Built on top of Homun-Lang v0.81 (@attr syntax for Rust attribute
passthrough — used throughout for @derive(Clone, Debug) on components).
Generated by claude-bot autonomous workers across 3 stories / 13 issues.