This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: ticket 002 fixed-step simulation and replay substrate

author
dev wells
date (Jun 17, 2026, 11:08 AM -0400) commit cffd93bb parent 066a7658 change-id nqqykwwu
+395 -41
+20 -1
AGENTS.md
··· 14 14 - Simulation framework in [sim](/src/sim) 15 15 - Library glue code in [lib](/src/lib) 16 16 17 + ## Performance 18 + 19 + Performance is paramount. This project by necessity is going to be very compute heavy, given the way that we're handling echoes and deterministic, replayable gameplay. 20 + 21 + For that to work at 60hz, we need to be always keeping in mind performance techniques, both for memory and speed. 22 + 23 + We should always be preferring simple, readable code that doesn't deopt. Simple loops. Careful memory management. We should be writing code that will work well with performance awareness: 24 + 25 + - SIMD 26 + - CPU cache 27 + - Parallelization 28 + 29 + This requires upfront thinking and consideration as we build out, especially for core systems. 30 + 17 31 ## Notes 18 32 19 - Use jujutsu (jj) for version control. 33 + - Use jujutsu (jj) for version control 34 + - Prefer literate programming styles (code should be readable and comments help tell the why/how alongside the code) 35 + 36 + ## External References 37 + 38 + [Odin documentation](https://odin-lang.org/docs/overview/)
+5 -1
justfile
··· 18 18 @just --list 19 19 20 20 # Check all template packages 21 - check: 21 + check: test-sim 22 22 odin check {{ sim_pkg }} -strict-style -vet -no-entry-point 23 23 odin check {{ game_pkg }} -strict-style -vet -no-entry-point 24 24 odin check {{ hot_loader_pkg }} -strict-style -vet 25 25 odin check {{ release_pkg }} -strict-style -vet 26 + 27 + # Run deterministic simulation tests without Raylib 28 + test-sim: 29 + odin test {{ sim_pkg }} -strict-style -vet 26 30 27 31 # Build the game shared library used by the hot reload loader 28 32 build-game:
+24
src/game/app.odin
··· 76 76 app_init :: proc(app: ^App_State) { 77 77 app^ = App_State{run = true} 78 78 simulation.sim_init(&app.sim) 79 + simulation.run_history_init(&app.history) 79 80 } 80 81 81 82 app_update :: proc(app: ^App_State) { ··· 84 85 app.run = false 85 86 } 86 87 88 + frame_dt := f64(rl.GetFrameTime()) 89 + if frame_dt > simulation.MAX_FRAME_DT { 90 + frame_dt = simulation.MAX_FRAME_DT 91 + } else if frame_dt < 0 { 92 + frame_dt = 0 93 + } 94 + 95 + app.sim_accumulator += frame_dt 96 + ticks_this_frame := 0 97 + for app.sim_accumulator >= simulation.SIM_DT && ticks_this_frame < simulation.MAX_TICKS_PER_FRAME { 98 + if !simulation.run_history_record_tick(&app.history, &app.sim, app.last_input) { 99 + app.run = false 100 + break 101 + } 102 + simulation.sim_tick(&app.sim, app.last_input) 103 + app.sim_accumulator -= simulation.SIM_DT 104 + ticks_this_frame += 1 105 + } 106 + if ticks_this_frame == simulation.MAX_TICKS_PER_FRAME && app.sim_accumulator >= simulation.SIM_DT { 107 + app.sim_accumulator = 0 108 + } 109 + 87 110 app.frame_index += 1 88 111 render_frame(app) 89 112 } 90 113 91 114 app_shutdown :: proc(app: ^App_State) { 115 + simulation.run_history_destroy(&app.history) 92 116 app^ = {} 93 117 }
+7 -23
src/game/input.odin
··· 3 3 import rl "vendor:raylib" 4 4 import simulation "../sim" 5 5 6 - DIAGONAL_MOVE_SCALE :: f32(0.70710677) 7 - 8 6 input_sample :: proc() -> simulation.Input_Command { 9 - command: simulation.Input_Command 10 - 11 - if rl.IsKeyDown(DEFAULT_KEYMAP.move_left) { 12 - command.move_x -= 1 13 - } 14 - if rl.IsKeyDown(DEFAULT_KEYMAP.move_right) { 15 - command.move_x += 1 16 - } 17 - if rl.IsKeyDown(DEFAULT_KEYMAP.move_up) { 18 - command.move_y -= 1 19 - } 20 - if rl.IsKeyDown(DEFAULT_KEYMAP.move_down) { 21 - command.move_y += 1 22 - } 23 - if command.move_x != 0 && command.move_y != 0 { 24 - command.move_x *= DIAGONAL_MOVE_SCALE 25 - command.move_y *= DIAGONAL_MOVE_SCALE 26 - } 27 - 28 - command.attack = rl.IsMouseButtonDown(DEFAULT_KEYMAP.attack) 29 - return command 7 + return simulation.input_command_from_digital( 8 + rl.IsKeyDown(DEFAULT_KEYMAP.move_left), 9 + rl.IsKeyDown(DEFAULT_KEYMAP.move_right), 10 + rl.IsKeyDown(DEFAULT_KEYMAP.move_up), 11 + rl.IsKeyDown(DEFAULT_KEYMAP.move_down), 12 + rl.IsMouseButtonDown(DEFAULT_KEYMAP.attack), 13 + ) 30 14 } 31 15 32 16 input_requested_quit :: proc() -> bool {
+6 -4
src/game/memory.odin
··· 3 3 import simulation "../sim" 4 4 5 5 App_State :: struct { 6 - run: bool, 7 - frame_index: u64, 8 - sim: simulation.Sim_State, 9 - last_input: simulation.Input_Command, 6 + run: bool, 7 + frame_index: u64, 8 + sim_accumulator: f64, 9 + sim: simulation.Sim_State, 10 + history: simulation.Run_History, 11 + last_input: simulation.Input_Command, 10 12 } 11 13 12 14 Game_Memory :: struct {
+10
src/sim/config.odin
··· 1 1 package sim 2 2 3 + SIM_VERSION :: u32(1) 4 + 3 5 // The simulation owns gameplay time. Rendering may run faster, slower, or jitter, 4 6 // but every gameplay decision should eventually reduce to integer ticks at this rate. 5 7 SIM_HZ :: 60 ··· 12 14 13 15 // Seeds are part of the replay contract: same initial state, same inputs, same result. 14 16 DEFAULT_WORLD_SEED :: u64(0x7468616E61746F) 17 + 18 + // Authoritative spatial state uses fixed-point integers. A power-of-two scale 19 + // keeps future conversion cheap and avoids division in the simulation hot path. 20 + FIXED_SHIFT :: 16 21 + FIXED_ONE :: Fixed(1 << FIXED_SHIFT) 22 + 23 + PLAYER_MOVE_CARDINAL :: FIXED_ONE 24 + PLAYER_MOVE_DIAGONAL :: Fixed(46341) // round(FIXED_ONE / sqrt(2))
+82 -4
src/sim/input.odin
··· 1 1 package sim 2 2 3 + Move_Direction :: enum u8 { 4 + None, 5 + N, 6 + NE, 7 + E, 8 + SE, 9 + S, 10 + SW, 11 + W, 12 + NW, 13 + } 14 + 15 + @(private="file") 16 + move_deltas: [Move_Direction]Vec2_Fixed = { 17 + .None = {Fixed(0), Fixed(0)}, 18 + .N = {Fixed(0), -PLAYER_MOVE_CARDINAL}, 19 + .NE = {PLAYER_MOVE_DIAGONAL, -PLAYER_MOVE_DIAGONAL}, 20 + .E = {PLAYER_MOVE_CARDINAL, Fixed(0)}, 21 + .SE = {PLAYER_MOVE_DIAGONAL, PLAYER_MOVE_DIAGONAL}, 22 + .S = {Fixed(0), PLAYER_MOVE_CARDINAL}, 23 + .SW = {-PLAYER_MOVE_DIAGONAL, PLAYER_MOVE_DIAGONAL}, 24 + .W = {-PLAYER_MOVE_CARDINAL, Fixed(0)}, 25 + .NW = {-PLAYER_MOVE_DIAGONAL, -PLAYER_MOVE_DIAGONAL}, 26 + } 27 + 3 28 // Input_Command is the narrow bridge between the outside world and deterministic 4 29 // simulation. Hardware, UI, and AI can all produce commands; sim only consumes them. 5 30 Input_Command :: struct { 6 - move_x: f32, 7 - move_y: f32, 8 - aim_x: f32, 9 - aim_y: f32, 31 + move: Move_Direction, 32 + aim: Vec2_Q, 10 33 attack: bool, 11 34 } 35 + 36 + input_command_from_digital :: proc( 37 + move_left, move_right, move_up, move_down: bool, 38 + attack := false, 39 + ) -> Input_Command { 40 + command: Input_Command 41 + command.move = move_direction_from_digital(move_left, move_right, move_up, move_down) 42 + command.attack = attack 43 + return command 44 + } 45 + 46 + move_direction_from_digital :: proc( 47 + move_left, move_right, move_up, move_down: bool, 48 + ) -> Move_Direction { 49 + x := i8(0) 50 + y := i8(0) 51 + 52 + if move_left { 53 + x -= 1 54 + } 55 + if move_right { 56 + x += 1 57 + } 58 + if move_up { 59 + y -= 1 60 + } 61 + if move_down { 62 + y += 1 63 + } 64 + 65 + switch { 66 + case x == 0 && y < 0: 67 + return .N 68 + case x > 0 && y < 0: 69 + return .NE 70 + case x > 0 && y == 0: 71 + return .E 72 + case x > 0 && y > 0: 73 + return .SE 74 + case x == 0 && y > 0: 75 + return .S 76 + case x < 0 && y > 0: 77 + return .SW 78 + case x < 0 && y == 0: 79 + return .W 80 + case x < 0 && y < 0: 81 + return .NW 82 + } 83 + 84 + return .None 85 + } 86 + 87 + move_delta :: proc(direction: Move_Direction) -> Vec2_Fixed { 88 + return move_deltas[direction] 89 + }
+26
src/sim/math.odin
··· 1 + package sim 2 + 3 + // Fixed is the authoritative numeric type for sim-space positions. Keeping this 4 + // distinct from raw integers makes accidental mixing of pixels, ticks, and fixed 5 + // simulation units a compile-time error instead of a replay bug. 6 + Fixed :: distinct i32 7 + 8 + // Vec2_Fixed stores replay-relevant positions and deltas. It is deliberately 9 + // integer-only; rendering can convert to floats later at the sim/render boundary. 10 + Vec2_Fixed :: struct { 11 + x: Fixed, 12 + y: Fixed, 13 + } 14 + 15 + // Vec2_Q is reserved for quantized command vectors such as aim/controller input. 16 + // It keeps persistent commands compact and canonical without storing floats. 17 + Vec2_Q :: struct { 18 + x: i16, 19 + y: i16, 20 + } 21 + 22 + // Convert whole simulation units into the fixed-point representation. This is a 23 + // shift because FIXED_ONE is a power of two, avoiding multiplication/division. 24 + fixed_from_whole :: proc(value: i32) -> Fixed { 25 + return Fixed(value << FIXED_SHIFT) 26 + }
+49 -2
src/sim/replay.odin
··· 3 3 // A run history is not a movie of positions. It is the command stream needed to 4 4 // ask the same simulation to produce the same behavior again. 5 5 Run_History :: struct { 6 - run_id: u32, 7 - commands: [dynamic]Input_Command, 6 + run_id: u32, 7 + sim_version: u32, 8 + seed: u64, 9 + initial_state: Initial_State_Descriptor, 10 + commands: [dynamic]Input_Command, 8 11 } 9 12 10 13 // Replay cursors let echoes consume a past run one tick at a time while the live ··· 14 17 history: ^Run_History, 15 18 tick: u64, 16 19 } 20 + 21 + run_history_init :: proc( 22 + history: ^Run_History, 23 + run_id := u32(0), 24 + seed := DEFAULT_WORLD_SEED, 25 + initial := DEFAULT_INITIAL_STATE, 26 + ) { 27 + history^ = Run_History{ 28 + run_id = run_id, 29 + sim_version = SIM_VERSION, 30 + seed = seed, 31 + initial_state = initial, 32 + commands = make([dynamic]Input_Command), 33 + } 34 + } 35 + 36 + run_history_destroy :: proc(history: ^Run_History) { 37 + delete(history.commands) 38 + history^ = {} 39 + } 40 + 41 + run_history_clear_commands :: proc(history: ^Run_History) { 42 + clear(&history.commands) 43 + } 44 + 45 + run_history_record_tick :: proc(history: ^Run_History, state: ^Sim_State, command: Input_Command) -> bool { 46 + if u64(len(history.commands)) != state.tick { 47 + return false 48 + } 49 + append(&history.commands, command) 50 + return true 51 + } 52 + 53 + run_history_command_count :: proc(history: ^Run_History) -> u64 { 54 + return u64(len(history.commands)) 55 + } 56 + 57 + replay_history :: proc(history: ^Run_History, state: ^Sim_State) -> u64 { 58 + sim_init(state, history.seed, history.initial_state) 59 + for command in history.commands { 60 + sim_tick(state, command) 61 + } 62 + return sim_digest(state) 63 + }
+78
src/sim/replay_test.odin
··· 1 + package sim 2 + 3 + import "core:testing" 4 + 5 + @(test) 6 + deterministic_replay_matches_original_run :: proc(t: ^testing.T) { 7 + history: Run_History 8 + run_history_init(&history, run_id = 7, seed = 12345) 9 + defer run_history_destroy(&history) 10 + 11 + state: Sim_State 12 + sim_init(&state, history.seed, history.initial_state) 13 + 14 + commands := [?]Input_Command{ 15 + {move = .E}, 16 + {move = .E}, 17 + {move = .N}, 18 + {move = .NE}, 19 + {move = .SW}, 20 + {}, 21 + {move = .S, attack = true}, 22 + } 23 + 24 + for command in commands { 25 + recorded := run_history_record_tick(&history, &state, command) 26 + testing.expect(t, recorded, "command history should align one command per tick") 27 + sim_tick(&state, command) 28 + } 29 + 30 + // The original run records only commands. The digest is derived after the fact 31 + // from canonical sim fields, proving replay without making positions authority. 32 + original_digest := sim_digest(&state) 33 + 34 + replay_state: Sim_State 35 + replay_digest := replay_history(&history, &replay_state) 36 + 37 + // Replay must land on the same tick, command count, digest, and explicit player 38 + // state when initialized from the same seed and fed the same command stream. 39 + testing.expect_value(t, replay_state.tick, state.tick) 40 + testing.expect_value(t, run_history_command_count(&history), state.tick) 41 + testing.expect_value(t, replay_digest, original_digest) 42 + testing.expect_value(t, i32(replay_state.world.player.position.x), i32(state.world.player.position.x)) 43 + testing.expect_value(t, i32(replay_state.world.player.position.y), i32(state.world.player.position.y)) 44 + testing.expect_value(t, i32(state.world.player.position.x), i32(fixed_from_whole(2))) 45 + testing.expect_value(t, i32(state.world.player.position.y), i32(fixed_from_whole(0))) 46 + } 47 + 48 + @(test) 49 + digital_diagonal_movement_uses_precomputed_normalized_delta :: proc(t: ^testing.T) { 50 + state: Sim_State 51 + sim_init(&state) 52 + 53 + // Diagonal digital input should normalize before it reaches sim_tick. The tick 54 + // path then uses a precomputed fixed-point delta instead of sqrt/division work. 55 + command := input_command_from_digital(false, true, true, false) 56 + sim_tick(&state, command) 57 + 58 + testing.expect_value(t, command.move, Move_Direction.NE) 59 + testing.expect_value(t, i32(state.world.player.position.x), i32(PLAYER_MOVE_DIAGONAL)) 60 + testing.expect_value(t, i32(state.world.player.position.y), i32(-PLAYER_MOVE_DIAGONAL)) 61 + } 62 + 63 + @(test) 64 + history_rejects_missing_command_ticks :: proc(t: ^testing.T) { 65 + history: Run_History 66 + run_history_init(&history) 67 + defer run_history_destroy(&history) 68 + 69 + state: Sim_State 70 + sim_init(&state) 71 + sim_tick(&state, {}) 72 + 73 + // Recording is tick-indexed: skipping a command for tick 0 makes the history 74 + // invalid rather than silently inserting a late command at tick 1. 75 + recorded := run_history_record_tick(&history, &state, {}) 76 + testing.expect(t, !recorded, "history should not accept a command after the tick was skipped") 77 + testing.expect_value(t, run_history_command_count(&history), u64(0)) 78 + }
+28
src/sim/rng.odin
··· 1 + package sim 2 + 3 + // Rng_State is owned by Sim_State so all gameplay randomness is seed-driven and 4 + // replayable. No gameplay code should use global/random platform state directly. 5 + Rng_State :: struct { 6 + value: u64, 7 + } 8 + 9 + // Initialize the deterministic random stream from the run seed. A zero seed is 10 + // remapped so the generator always has a useful non-zero state to advance from. 11 + rng_from_seed :: proc(seed: u64) -> Rng_State { 12 + state := seed 13 + if state == 0 { 14 + state = 0x9E3779B97F4A7C15 15 + } 16 + return Rng_State{value = state} 17 + } 18 + 19 + // Advance and return the next deterministic random value. SplitMix64 is small, 20 + // fast, and stable enough for this substrate; if later gameplay needs separate 21 + // random domains, those should become explicit named streams in Sim_State. 22 + rng_next_u64 :: proc(rng: ^Rng_State) -> u64 { 23 + rng.value += 0x9E3779B97F4A7C15 24 + z := rng.value 25 + z = (z ~ (z >> 30)) * 0xBF58476D1CE4E5B9 26 + z = (z ~ (z >> 27)) * 0x94D049BB133111EB 27 + return z ~ (z >> 31) 28 + }
+46 -6
src/sim/sim.odin
··· 3 3 // Sim_State is the authoritative gameplay state. If a value affects replay, 4 4 // combat, collision, death, loot, or echo behavior, it belongs here or below here. 5 5 Sim_State :: struct { 6 - tick: u64, 7 - world: World_State, 6 + tick: u64, 7 + rng: Rng_State, 8 + initial: Initial_State_Descriptor, 9 + world: World_State, 8 10 } 9 11 10 12 // Initialization must be explicit and seed-driven so tests and replays can create 11 13 // identical worlds without opening a window or touching platform APIs. 12 - sim_init :: proc(state: ^Sim_State, seed := DEFAULT_WORLD_SEED) { 14 + sim_init :: proc( 15 + state: ^Sim_State, 16 + seed := DEFAULT_WORLD_SEED, 17 + initial := DEFAULT_INITIAL_STATE, 18 + ) { 13 19 state^ = Sim_State{ 20 + rng = rng_from_seed(seed), 21 + initial = initial, 14 22 world = World_State{ 15 23 seed = seed, 24 + actor_count = 1, 25 + player = Actor_State{ 26 + id = Actor_Id{index = 0, generation = 1}, 27 + position = initial.player_position, 28 + }, 16 29 }, 17 30 } 18 31 } 19 32 20 - // sim_tick is the only place gameplay time advances. Today it is intentionally 21 - // inert; T002 will start moving state through this fixed-step path. 33 + // sim_tick is the only place gameplay time advances. It intentionally takes an 34 + // already-canonical command so hardware, frame time, and replay never enter sim. 22 35 sim_tick :: proc(state: ^Sim_State, command: Input_Command) { 23 - _ = command 36 + delta := move_delta(command.move) 37 + state.world.player.position.x += delta.x 38 + state.world.player.position.y += delta.y 24 39 state.tick += 1 25 40 } 41 + 42 + sim_digest :: proc(state: ^Sim_State) -> u64 { 43 + hash := u64(14695981039346656037) 44 + hash = digest_mix(hash, u64(SIM_VERSION)) 45 + hash = digest_mix(hash, state.tick) 46 + hash = digest_mix(hash, state.rng.value) 47 + hash = digest_mix(hash, state.world.seed) 48 + hash = digest_mix(hash, u64(state.world.actor_count)) 49 + hash = digest_mix_fixed(hash, state.initial.player_position.x) 50 + hash = digest_mix_fixed(hash, state.initial.player_position.y) 51 + hash = digest_mix(hash, u64(state.world.player.id.index)) 52 + hash = digest_mix(hash, u64(state.world.player.id.generation)) 53 + hash = digest_mix_fixed(hash, state.world.player.position.x) 54 + hash = digest_mix_fixed(hash, state.world.player.position.y) 55 + return hash 56 + } 57 + 58 + digest_mix :: proc(hash, value: u64) -> u64 { 59 + return (hash ~ value) * 1099511628211 60 + } 61 + 62 + digest_mix_fixed :: proc(hash: u64, value: Fixed) -> u64 { 63 + bits := u32(i32(value)) 64 + return digest_mix(hash, u64(bits)) 65 + }
+14
src/sim/world.odin
··· 8 8 generation: u32, 9 9 } 10 10 11 + Actor_State :: struct { 12 + id: Actor_Id, 13 + position: Vec2_Fixed, 14 + } 15 + 16 + Initial_State_Descriptor :: struct { 17 + player_position: Vec2_Fixed, 18 + } 19 + 20 + DEFAULT_INITIAL_STATE :: Initial_State_Descriptor { 21 + player_position = {Fixed(0), Fixed(0)}, 22 + } 23 + 11 24 // World_State is the gameplay data owned by a single deterministic run. 12 25 // It should stay free of renderer, asset, and platform concepts. 13 26 World_State :: struct { 14 27 seed: u64, 15 28 actor_count: u32, 29 + player: Actor_State, 16 30 }