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
//! Lockstep networking: peers fed the same inputs end with the same world.
use std::thread;
use std::time::Duration;

use ponko::*;
use serde::{Deserialize, Serialize};

/// What a player does in a tick.
#[derive(Serialize, Deserialize, Clone, Copy)]
struct Steer {
    dir: Vec2,
}

/// A crowd member; `owner` steers it.
#[derive(Serialize, Deserialize, Clone, Copy)]
struct Unit {
    owner: u32,
    vel: Vec2,
}

/// What a run ended with, read in its last tick.
#[derive(Clone)]
struct Outcome {
    desync: Option<Desync>,
    player: usize,
    steered: bool,
}

/// Inputs vary with tick and player, as if someone were playing.
fn play_along(ctx: &mut Ctx) {
    let t = ctx.sim_tick() as f32 * 0.05 + ctx.net.player() as f32;
    ctx.net.set_input(&Steer {
        dir: vec2(t.cos(), t.sin()),
    });
}

/// Every player steers its units; units also wander with seeded randomness
/// and push apart through the spatial grid, so any difference in inputs or
/// order spreads through the whole crowd.
fn crowd(ctx: &mut Ctx) {
    let mut steer = [Vec2::ZERO; MAX_PLAYERS];
    for (p, s) in steer.iter_mut().enumerate().take(ctx.net.players()) {
        if let Some(Steer { dir }) = ctx.net.input::<Steer>(p) {
            *s = dir;
        }
    }
    if steer[1] != Vec2::ZERO {
        ctx.world.resource_mut::<Steered>().0 = true;
    }
    let random = ctx.random();
    let dt = ctx.dt();
    let grid = &ctx.spatial;
    let mut moves = Vec::new();
    for (e, t, u) in ctx.world.query::<(Entity, &Transform2D, &Unit)>().iter() {
        let mut push = Vec2::ZERO;
        grid.for_each_in_radius(t.position, 16.0, |other| {
            if other.entity != e {
                push += (t.position - other.position).normalize_or_zero();
            }
        });
        let jitter = random.of(e).unit_vec2() * 5.0;
        let vel = u.vel * 0.9 + steer[u.owner as usize] * 20.0 + push * 10.0 + jitter;
        moves.push((e, vel, t.position + vel * dt));
    }
    for (e, vel, at) in moves {
        ctx.world.get_mut::<Unit>(e).unwrap().vel = vel;
        ctx.world.get_mut::<Transform2D>(e).unwrap().position = at;
    }
}

struct Steered(bool);

fn spawn_crowd(ctx: &mut Ctx, units: usize, players: usize) {
    ctx.world.reflect::<Unit>();
    ctx.world.insert_resource(Steered(false));
    let mut rng = ctx.random().stream(7);
    for i in 0..units {
        ctx.world.spawn((
            Transform2D::at(vec2(rng.range(0.0, 400.0), rng.range(0.0, 400.0))),
            Unit {
                owner: (i % players) as u32,
                vel: Vec2::ZERO,
            },
        ));
    }
}

/// Runs each session in its own thread for `ticks`; `tweak(player)` is an
/// extra sim system, to break determinism on purpose.
fn play(nets: Vec<Net>, ticks: u64, units: usize, tweak: fn(&mut Ctx)) -> Vec<(Value, Outcome)> {
    let players = nets.len();
    let runs: Vec<_> = nets
        .into_iter()
        .map(|net| {
            thread::spawn(move || {
                let mut world = Game::new("net", 1, 1)
                    .spatial_grid(16.0)
                    .lockstep(net)
                    .sim_system(play_along)
                    .sim_system(crowd)
                    .sim_system(tweak)
                    .sim_system(move |ctx: &mut Ctx| {
                        if ctx.sim_tick() + 1 == ticks {
                            let outcome = Outcome {
                                desync: ctx.net.desync().cloned(),
                                player: ctx.net.player(),
                                steered: ctx.world.resource::<Steered>().0,
                            };
                            ctx.world.insert_resource(outcome);
                        }
                    })
                    .run_headless(ticks, |ctx| spawn_crowd(ctx, units, players));
                let outcome = world.remove_resource::<Outcome>().expect("ran every tick");
                (world.save(), outcome)
            })
        })
        .collect();
    runs.into_iter().map(|r| r.join().unwrap()).collect()
}

fn nothing(_: &mut Ctx) {}

fn assert_identical(results: &[(Value, Outcome)]) {
    // The host is player 0; guests are numbered in the order their hellos
    // arrive, which packet loss can shuffle.
    let mut players: Vec<usize> = results.iter().map(|(_, o)| o.player).collect();
    assert_eq!(players[0], 0, "the host is player 0");
    players.sort_unstable();
    assert_eq!(
        players,
        (0..results.len()).collect::<Vec<_>>(),
        "every player has its own number"
    );
    for (i, (save, outcome)) in results.iter().enumerate() {
        assert!(outcome.desync.is_none(), "no desync: {:?}", outcome.desync);
        assert!(outcome.steered, "player 1's input arrived");
        assert_eq!(*save, results[0].0, "player {i} has the same world");
    }
}

// ANCHOR: loopback
#[test]
fn two_peers_run_ten_thousand_ticks_bit_identical() {
    // `play` runs each session in its own thread and returns every world.
    assert_identical(&play(Net::loopback(2), 10_000, 200, nothing));
}
// ANCHOR_END: loopback

#[test]
fn lost_packets_are_recovered() {
    // One packet in three never arrives.
    assert_identical(&play(Net::loopback_lossy(3, 3), 1000, 150, nothing));
}

#[test]
fn peers_over_udp_stay_in_sync() {
    let host = Net::host("127.0.0.1:0", 2).unwrap().input_delay(2);
    let addr = host.local_addr().unwrap();
    let guest = Net::join(addr).unwrap();
    assert_identical(&play(vec![host, guest], 600, 100, nothing));
}

/// Player 1 alone nudges one unit: a bug that only shows on one machine.
fn diverge(ctx: &mut Ctx) {
    if ctx.net.player() == 1 && ctx.sim_tick() == 100 {
        let e = ctx.world.query::<Entity>().with::<Unit>().iter().next().unwrap();
        ctx.world.get_mut::<Unit>(e).unwrap().vel.x += 0.001;
    }
}

#[test]
fn a_desync_is_detected_and_named() {
    let results = play(Net::loopback(2), 400, 50, diverge);
    for (_, outcome) in &results {
        let d = outcome.desync.as_ref().expect("both sides notice");
        assert_eq!(d.tick, 119, "the first check after the change (every 60 ticks)");
        assert!(d.components.contains(&"Unit".to_string()), "{d}");
    }
    assert_eq!(results[0].1.desync.as_ref().unwrap().player, 1);
    assert_eq!(results[1].1.desync.as_ref().unwrap().player, 0);
}

#[test]
fn a_different_seed_is_refused() {
    let mut nets = Net::loopback(2);
    let guest = nets.pop().unwrap().timeout(Duration::from_secs(2));
    let host = nets.pop().unwrap().timeout(Duration::from_secs(2));
    let host = thread::spawn(move || {
        let world = Game::new("h", 1, 1).lockstep(host).run_headless(10, |_| {});
        world.len()
    });
    let mut state = None;
    Game::new("g", 1, 1).seed(99).lockstep(guest).run_headless(10, |ctx| {
        state = Some(ctx.net.state());
    });
    assert_eq!(state, Some(NetState::Connecting));
    assert_eq!(host.join().unwrap(), 0, "the host gave up waiting too");
}

#[test]
fn offline_input_applies_on_the_next_tick() {
    let seen = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
    let log = seen.clone();
    Game::new("t", 1, 1)
        .sim_system(move |ctx: &mut Ctx| {
            log.borrow_mut().push(ctx.net.input::<u64>(0));
            ctx.net.set_input(&ctx.sim_tick());
        })
        .run_headless(3, |ctx| {
            assert_eq!((ctx.net.player(), ctx.net.players()), (0, 1));
            assert_eq!(ctx.net.state(), NetState::Offline);
        });
    assert_eq!(*seen.borrow(), [None, Some(0), Some(1)]);
}