Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hecs_demo — gametok POC

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.

Layout

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

Build & run

Workspace builds inside this directory:

cd hecs_demo
cargo build
cargo run -p runner

Expected 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

Hot-load demo (no recompile)

# 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 game2

The runner binary is unchanged between runs. Adding a game3 cdylib that exports extern "C" fn tick(dt: f32) would work the same way.

How it works

FFI contract

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.

thread_local World per game

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.

Cooperative shutdown sequence

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 — safe

Rust 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.

Why #![allow(...)] in lib.rs

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.

Adding a third game (gametok extensibility)

  1. Create game3/ mirroring game1's structure.
  2. Add "game3" to Cargo.toml workspace members.
  3. Write game3/src/main.hom with components + a main_tick(dt) function.
  4. Copy game3/src/lib.rs and game3/src/hom_hecs/ from game1 (no edits).
  5. cargo build produces target/debug/libgame3.so.
  6. The runner picks it up on next launch — no code changes.

Out of scope

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)

Provenance

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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages