ponko 0.0.1

A fast 2D game engine for simulating millions of things: ECS on all cores, hot reload, an in-game inspector, AI-agent control, and a C++ API.
Documentation
//! Headless runs: simulation without a window or GPU (`Game::run_headless`).

use std::sync::atomic::{AtomicU32, Ordering};

use ponko::*;

struct Pos(f32);
struct Ticks(Vec<u64>);

fn fall(ctx: &mut Ctx) {
    let dt = ctx.dt();
    ctx.world.query::<&mut Pos>().for_each(|p| p.0 -= 10.0 * dt);
}

fn record_tick(ctx: &mut Ctx) {
    let t = ctx.sim_tick();
    ctx.world.resource_mut::<Ticks>().0.push(t);
}

#[test]
fn runs_the_requested_number_of_ticks() {
    let world = Game::new("t", 800, 600).sim_system(record_tick).run_headless(5, |ctx| {
        ctx.world.insert_resource(Ticks(Vec::new()));
    });
    assert_eq!(world.resource::<Ticks>().0, [0, 1, 2, 3, 4]);
}

#[test]
fn dt_is_the_fixed_step() {
    let mut world = Game::new("t", 800, 600)
        .fixed_hz(100)
        .sim_system(fall)
        .run_headless(100, |ctx| {
            ctx.world.spawn((Pos(50.0),));
        });
    let y = world.query::<&Pos>().iter().next().unwrap().0;
    assert!((y - 40.0).abs() < 1e-3, "one simulated second at 10/s, got {y}");
}

#[test]
fn sim_time_counts_ticks() {
    struct Seen(f64);
    fn see(ctx: &mut Ctx) {
        let t = ctx.sim_time();
        ctx.world.resource_mut::<Seen>().0 = t;
    }
    let world = Game::new("t", 1, 1)
        .fixed_hz(50)
        .sim_system(see)
        .run_headless(50, |ctx| {
            ctx.world.insert_resource(Seen(0.0));
        });
    // The last tick sees 49 completed ticks.
    assert!((world.resource::<Seen>().0 - 49.0 / 50.0).abs() < 1e-9);
}

#[test]
fn quit_stops_early() {
    fn quit_at_3(ctx: &mut Ctx) {
        record_tick(ctx);
        if ctx.sim_tick() == 3 {
            ctx.quit();
        }
    }
    let world = Game::new("t", 1, 1).sim_system(quit_at_3).run_headless(1000, |ctx| {
        ctx.world.insert_resource(Ticks(Vec::new()));
    });
    assert_eq!(world.resource::<Ticks>().0, [0, 1, 2, 3]);
}

#[test]
fn frame_systems_and_setup_closure_do_not_run() {
    static FRAME_RUNS: AtomicU32 = AtomicU32::new(0);
    fn frame(_: &mut Ctx) {
        FRAME_RUNS.fetch_add(1, Ordering::Relaxed);
    }
    Game::new("t", 1, 1).system(frame).run_headless(10, |_| {
        |_: &mut Ctx| {
            FRAME_RUNS.fetch_add(1, Ordering::Relaxed);
        }
    });
    assert_eq!(FRAME_RUNS.load(Ordering::Relaxed), 0);
}

#[test]
fn commands_from_setup_and_systems_apply() {
    struct Spawner;
    fn spawn_one(ctx: &mut Ctx) {
        ctx.commands.spawn((Pos(0.0),));
    }
    let world = Game::new("t", 1, 1).sim_system(spawn_one).run_headless(7, |ctx| {
        ctx.commands.spawn((Spawner,));
    });
    assert_eq!(world.len(), 8);
}

#[test]
fn textures_load_without_a_gpu() {
    let world = Game::new("t", 1, 1).run_headless(1, |ctx| {
        let crab = ctx.load_texture("crab.png");
        assert!(crab.width() > 0 && crab.height() > 0);
        let made = ctx.create_texture(2, 3, &[255; 24]);
        assert_eq!((made.width(), made.height()), (2, 3));
        assert_ne!(crab, made);
        ctx.world.spawn((Transform2D::default(), Sprite::new(crab)));
    });
    assert_eq!(world.len(), 1);
}

#[test]
fn sim_speed_rejects_negative() {
    let result = std::panic::catch_unwind(|| {
        Game::new("t", 1, 1).run_headless(0, |ctx| ctx.set_sim_speed(-1.0));
    });
    assert!(result.is_err());
}

// ---------------------------------------------------------- reduced rates

#[derive(Default)]
struct Log(Vec<(&'static str, u64, f32)>);

fn logger(name: &'static str) -> impl FnMut(&mut Ctx) {
    move |ctx| {
        let (tick, dt) = (ctx.sim_tick(), ctx.dt());
        ctx.world.resource_mut::<Log>().0.push((name, tick, dt));
    }
}

#[test]
fn every_n_systems_run_every_nth_tick_with_n_steps_of_dt() {
    let world = Game::new("t", 1, 1)
        .fixed_hz(60)
        .sim_system_every(4, logger("slow"))
        .sim_system(logger("fast"))
        .run_headless(12, |ctx| ctx.world.insert_resource(Log::default()));
    let log = &world.resource::<Log>().0;
    let slow: Vec<u64> = log.iter().filter(|l| l.0 == "slow").map(|l| l.1).collect();
    assert_eq!(slow, [0, 4, 8]);
    assert_eq!(log.iter().filter(|l| l.0 == "fast").count(), 12);
    for &(name, _, dt) in log {
        let want = if name == "slow" { 4.0 / 60.0 } else { 1.0 / 60.0 };
        assert!((dt - want).abs() < 1e-6, "{name}: {dt}");
    }
}

#[test]
fn systems_with_the_same_interval_run_on_different_ticks() {
    let world = Game::new("t", 1, 1)
        .sim_system_every(3, logger("a"))
        .sim_system_every(3, logger("b"))
        .sim_system_every(3, logger("c"))
        .run_headless(6, |ctx| ctx.world.insert_resource(Log::default()));
    let ticks = |n| -> Vec<u64> {
        world
            .resource::<Log>()
            .0
            .iter()
            .filter(|l| l.0 == n)
            .map(|l| l.1)
            .collect()
    };
    assert_eq!(
        (ticks("a"), ticks("b"), ticks("c")),
        (vec![0, 3], vec![1, 4], vec![2, 5])
    );
}

#[test]
#[should_panic(expected = "interval must be at least 1 tick")]
fn every_zero_ticks_panics() {
    let _ = Game::new("t", 1, 1).sim_system_every(0, |_: &mut Ctx| {});
}

struct Turned(u32);

fn take_turns(ctx: &mut Ctx) {
    let turn = ctx.turns(5);
    ctx.world.query::<(Entity, &mut Turned)>().par_for_each(|(e, t)| {
        if turn.includes(e) {
            t.0 += 1;
        }
    });
}

#[test]
fn each_entity_takes_one_turn_per_interval() {
    let mut world = Game::new("t", 1, 1).sim_system(take_turns).run_headless(5, |ctx| {
        ctx.world.spawn_batch((0..1003).map(|_| (Turned(0),)));
    });
    assert!(world.query::<&Turned>().iter().all(|t| t.0 == 1));
}

#[test]
#[should_panic(expected = "`every` must be at least 1")]
fn turns_of_zero_panics() {
    Game::new("t", 1, 1).run_headless(0, |ctx| {
        ctx.turns(0);
    });
}

// ---------------------------------------------------------------- textures

#[test]
fn update_texture_works_headless() {
    Game::new("t", 1, 1).run_headless(0, |ctx| {
        let tex = ctx.create_texture(2, 2, &[0; 16]);
        ctx.update_texture(&tex, &[255; 16]);
    });
}

#[test]
#[should_panic(expected = "a 2 x 2 texture needs 16 bytes of RGBA, got 4")]
fn update_texture_checks_the_size() {
    Game::new("t", 1, 1).run_headless(0, |ctx| {
        let tex = ctx.create_texture(2, 2, &[0; 16]);
        ctx.update_texture(&tex, &[0; 4]);
    });
}

#[test]
fn headless_runs_have_no_render_stats_or_screenshots() {
    let path = std::env::temp_dir().join(format!("crabby-headless-shot-{}.png", std::process::id()));
    let p = path.clone();
    let ran = std::rc::Rc::new(std::cell::Cell::new(0));
    let r = ran.clone();
    Game::new("t", 1, 1)
        .sim_system(move |ctx: &mut Ctx| {
            ctx.save_screenshot(&p);
            assert_eq!(ctx.render_stats(), RenderStats::default());
            r.set(r.get() + 1);
        })
        .run_headless(3, |_| {});
    assert_eq!(ran.get(), 3);
    assert!(!path.exists(), "nothing to capture without a window");
}

#[cfg(not(feature = "window"))]
#[test]
#[should_panic(expected = "Game::run needs a window")]
fn run_without_the_window_feature_says_to_use_run_headless() {
    Game::new("no window", 320, 240).run(|_| {});
}

#[test]
fn loading_a_texture_twice_gives_the_same_handle() {
    Game::new("cache", 1, 1).run_headless(0, |ctx| {
        let a = ctx.load_texture("crab.png");
        let b = ctx.load_texture("crab.png");
        assert_eq!(a, b);
        assert_ne!(a, ctx.create_texture(1, 1, &[0; 4]), "created textures are their own");
    });
}