├── .gitignore ├── LICENSE ├── README.md ├── bullet.png ├── fixed_timestep_demo.odin ├── interpolated.gif ├── no_smoothing.gif ├── player_idle.png ├── player_walk.png ├── reference.gif └── render_tick.gif /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | ols.json 3 | odinfmt.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jakub Tomšů 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⏲️ Fixed Timestep Demo 2 | 3 | This is a demo to show a number of different ways to render a game with a fixed-timestep simulation. 4 | 5 | You can read [my article](https://jakubtomsu.github.io/posts/fixed_timestep_without_interpolation) to learn more about the methods I implement in the demo. I also recommend [Fix your Timestep!](https://www.gafferongames.com/post/fix_your_timestep/) article by Glenn Fiedler. 6 | 7 | ## Overview 8 | This is a short overview of the most important methods in the demo: 9 | - Interpolation: Always smooth. Needs a way to interpolate two game states, this can be a pain to implement. Always lags one tick behind - this means worse latency, especially with low TPS. 10 | - Render Tick: No added latency, matches real time "perfectly". Very easy to implement, especially if your game state and tick is set up in a nice way. However the single render tick can get inaccurate with low TPS because each update step is linear. 11 | - Accumulated Render Tick: More accurate in low TPS simulations, however because of the input frequency is so different between the predicted and the fixed ticks it can get out of sync. 12 | 13 |

14 |

15 | 16 | 17 |

18 |

19 | 20 | 21 |

22 |

23 | 24 | ## Build 25 | - Set up the Odin Compiler, either from [Github Releases](https://github.com/odin-lang/Odin/releases) or by [compiling it yourself](https://odin-lang.org/docs/install/) 26 | - run `odin run .` from the root folder of this project. This builds and executes `fixed_timestep_demo.odin` 27 | 28 | ## Controls 29 | - Use WASD to move the player, space to dash and M to shoot a bullet. 30 | - Use left/right arrow keys to change the simulation mode 31 | - Use up/down arrow keys to change the TPS 32 | 33 | # Credit 34 | - [Adventurer character art](https://sscary.itch.io/the-adventurer-male) 35 | - [Bullet art](https://bdragon1727.itch.io/free-effect-and-bullet-16x16) 36 | -------------------------------------------------------------------------------- /bullet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/bullet.png -------------------------------------------------------------------------------- /fixed_timestep_demo.odin: -------------------------------------------------------------------------------- 1 | /* 2 | This is a demo to show a number of different ways to render a game with a fixed-timestep simulation. 3 | 4 | By Jakub Tomšů 5 | 6 | Read https://jakubtomsu.github.io/posts/fixed_timestep_without_interpolation for more info. 7 | 8 | Controls 9 | - Use WASD to move the player, space to dash and M to shoot a bullet. 10 | - Use left/right arrow keys to change the simulation mode 11 | - Use up/down arrow keys to change the TPS 12 | */ 13 | package fixed_timestep_demo 14 | 15 | import rl "vendor:raylib" 16 | import "vendor:raylib/rlgl" 17 | import sa "core:container/small_array" 18 | import "core:math" 19 | import "core:math/linalg" 20 | import "core:fmt" 21 | import "base:runtime" 22 | 23 | WINDOW_SIZE_X :: 1000 24 | WINDOW_SIZE_Y :: 700 25 | 26 | _tick_nums := [?]f32 { 27 | 1, 28 | 2, 29 | 4, 30 | 5, 31 | 10, 32 | 15, 33 | 30, 34 | 40, 35 | 50, 36 | 60, 37 | 75, 38 | 100, 39 | 120, 40 | 180, 41 | 240, 42 | 480, 43 | } 44 | 45 | // "Fixed_Interpolated" and "Fixed_Render_Tick" are by far the most important 46 | Simulation_Mode :: enum u8 { 47 | // This is always "smooth" but not fixed timestep. 48 | // Sort of like a reference implementation. 49 | Frame = 0, 50 | Fixed_No_Smooth, 51 | Fixed_Interpolated, 52 | Fixed_Render_Tick, 53 | Fixed_Accumulated_Render_Tick, 54 | } 55 | 56 | g_player_walk_texture: rl.Texture 57 | g_player_idle_texture: rl.Texture 58 | g_bullet_texture: rl.Texture 59 | PLAYER_SPRITE_SIZE_X :: 48 60 | PLAYER_SPRITE_SIZE_Y :: 64 61 | BULLET_SPRITE_SIZE_X :: 16 62 | BULLET_SPRITE_SIZE_Y :: 16 63 | 64 | main :: proc() { 65 | rl.SetConfigFlags({.VSYNC_HINT, .MSAA_4X_HINT}) 66 | rl.InitWindow(1000, 700, "Fixed Timestep Demo") 67 | defer rl.CloseWindow() 68 | 69 | g_player_idle_texture = rl.LoadTexture("player_idle.png") 70 | g_player_walk_texture = rl.LoadTexture("player_walk.png") 71 | g_bullet_texture = rl.LoadTexture("bullet.png") 72 | 73 | tick_input: Input 74 | game: Game 75 | temp_game: Game 76 | interpolated_game: Game // you don't need this in practice! 77 | 78 | game.player_pos = {WINDOW_SIZE_X, WINDOW_SIZE_Y} / 2 79 | 80 | sim_mode: Simulation_Mode 81 | delta_index: i32 = 2 82 | slowmo: bool 83 | 84 | accumulator: f32 85 | prev_accumulator: f32 86 | 87 | draw_debug := true 88 | 89 | for !rl.WindowShouldClose() { 90 | rl.BeginDrawing() 91 | defer rl.EndDrawing() 92 | rl.ClearBackground({40, 45, 50, 255}) 93 | 94 | delta := 1.0 / _tick_nums[delta_index] 95 | 96 | if rl.IsKeyPressed(.UP) do delta_index = (delta_index + 1) %% len(_tick_nums) 97 | if rl.IsKeyPressed(.DOWN) do delta_index = (delta_index - 1) %% len(_tick_nums) 98 | 99 | if rl.IsKeyPressed(.RIGHT) do sim_mode = Simulation_Mode((int(sim_mode) + 1) %% len(Simulation_Mode)) 100 | if rl.IsKeyPressed(.LEFT) do sim_mode = Simulation_Mode((int(sim_mode) - 1) %% len(Simulation_Mode)) 101 | 102 | if rl.IsKeyPressed(.R) do slowmo = !slowmo 103 | if rl.IsKeyPressed(.F) do draw_debug = !draw_debug 104 | 105 | // Note: this input handling is a bit silly with raylib but it's good enough for this example 106 | 107 | frame_time := rl.GetFrameTime() 108 | if slowmo do frame_time *= 0.1 109 | accumulator += frame_time 110 | 111 | frame_input: Input 112 | frame_input.cursor = rl.GetMousePosition() 113 | frame_input.actions[.Left ] = input_flags_from_key(.A) 114 | frame_input.actions[.Right] = input_flags_from_key(.D) 115 | frame_input.actions[.Up ] = input_flags_from_key(.W) 116 | frame_input.actions[.Down ] = input_flags_from_key(.S) 117 | frame_input.actions[.Dash ] = input_flags_from_key(.SPACE) 118 | frame_input.actions[.Shoot] = input_flags_from_key(.M) 119 | 120 | tick_input.cursor = frame_input.cursor 121 | // _accumulate_ temp flags instead of overwriting 122 | for flags, action in frame_input.actions { 123 | tick_input.actions[action] += flags 124 | } 125 | 126 | // Note: some of the code is duplicated between some of the cases 127 | // just to make it clearer, since you probably care only one particluar case. 128 | switch sim_mode { 129 | case .Frame: 130 | game_tick(&game, frame_input, rl.GetFrameTime()) 131 | game_draw(game) 132 | accumulator = 0 133 | // The rest is just to make the transition to other sim modes less harsh, 134 | // feel free to ignore it. 135 | tick_input = {} 136 | runtime.mem_copy_non_overlapping(&temp_game, &game, size_of(Game)) 137 | 138 | case .Fixed_No_Smooth: 139 | any_tick := accumulator > delta 140 | defer if any_tick do tick_input = {} 141 | for ;accumulator > delta; accumulator -= delta { 142 | game_tick(&game, tick_input, delta) 143 | input_clear_temp(&tick_input) 144 | } 145 | game_draw(game) 146 | 147 | case .Fixed_Interpolated: 148 | any_tick := accumulator > delta 149 | defer if any_tick do tick_input = {} 150 | for ;accumulator > delta; accumulator -= delta { 151 | runtime.mem_copy_non_overlapping(&temp_game, &game, size_of(Game)) 152 | game_tick(&game, tick_input, delta) 153 | input_clear_temp(&tick_input) 154 | } 155 | alpha := accumulator / delta 156 | runtime.mem_copy_non_overlapping(&interpolated_game, &game, size_of(Game)) 157 | game_interpolate(&interpolated_game, game, temp_game, alpha) 158 | if draw_debug { 159 | game_draw(temp_game, rl.Fade(rl.RED, 0.8)) 160 | game_draw(game, rl.Fade(rl.GREEN, 0.8)) 161 | } 162 | game_draw(interpolated_game) 163 | 164 | case .Fixed_Render_Tick: 165 | any_tick := accumulator > delta 166 | defer if any_tick do tick_input = {} 167 | for ;accumulator > delta; accumulator -= delta { 168 | game_tick(&game, tick_input, delta) 169 | input_clear_temp(&tick_input) 170 | } 171 | runtime.mem_copy_non_overlapping(&temp_game, &game, size_of(Game)) 172 | game_tick(&temp_game, tick_input, accumulator) 173 | if draw_debug { 174 | game_draw(game, rl.Fade(rl.RED, 0.8)) 175 | } 176 | game_draw(temp_game) 177 | 178 | case .Fixed_Accumulated_Render_Tick: 179 | any_tick := accumulator > delta 180 | defer if any_tick do tick_input = {} 181 | for ;accumulator > delta; accumulator -= delta { 182 | game_tick(&game, tick_input, delta) 183 | input_clear_temp(&tick_input) 184 | } 185 | if any_tick { 186 | runtime.mem_copy_non_overlapping(&temp_game, &game, size_of(Game)) 187 | prev_accumulator = 0 188 | } 189 | // input_clear_temp(&frame_input) // ! 190 | game_tick(&temp_game, frame_input, accumulator - prev_accumulator) 191 | if draw_debug { 192 | game_draw(game, rl.Fade(rl.RED, 0.8)) 193 | } 194 | game_draw(temp_game) 195 | prev_accumulator = accumulator 196 | } 197 | 198 | action_colors: [Input_Action]rl.Color 199 | for action in Input_Action { 200 | mask := bit_set[Input_Flag]{.Down, .Pressed} 201 | if frame_input.actions[action] & mask != {} { 202 | action_colors[action] = {220, 230, 240, 255} 203 | } else if tick_input.actions[action] & mask != {} { 204 | action_colors[action] = {50, 150, 150, 255} 205 | } else { 206 | action_colors[action] = {80, 90, 100, 255} 207 | } 208 | } 209 | 210 | rl.DrawRectangleV({10, WINDOW_SIZE_Y - 60}, 20, action_colors[.Left]) 211 | rl.DrawRectangleV({70, WINDOW_SIZE_Y - 60}, 20, action_colors[.Right]) 212 | rl.DrawRectangleV({40, WINDOW_SIZE_Y - 90}, 20, action_colors[.Up]) 213 | rl.DrawRectangleV({40, WINDOW_SIZE_Y - 30}, 20, action_colors[.Down]) 214 | 215 | rl.DrawRectangleV({120, WINDOW_SIZE_Y - 30}, 20, action_colors[.Dash]) 216 | rl.DrawRectangleV({160, WINDOW_SIZE_Y - 30}, 20, action_colors[.Shoot]) 217 | 218 | rl.DrawFPS(2, 2) 219 | rl.DrawText(fmt.ctprintf("Sim Mode: {} (left/right)", sim_mode), 2, 22, 20, rl.WHITE) 220 | rl.DrawText(fmt.ctprintf("Delta: {} ({} TPS) (up/down)", delta, _tick_nums[delta_index]), 2, 44, 20, rl.WHITE) 221 | rl.DrawText(fmt.ctprintf("Accumulator: {}", accumulator), 2, 66, 20, rl.WHITE) 222 | } 223 | } 224 | 225 | 226 | 227 | //////////////////////////////////////////////////////////////////////////////////// 228 | // Input 229 | // 230 | 231 | Input :: struct { 232 | cursor: rl.Vector2, 233 | actions: [Input_Action]bit_set[Input_Flag], 234 | } 235 | 236 | Input_Action :: enum u8 { 237 | Left, 238 | Right, 239 | Up, 240 | Down, 241 | Dash, 242 | Shoot, 243 | } 244 | 245 | Input_Flag :: enum u8 { 246 | Down, 247 | Pressed, 248 | Released, 249 | } 250 | 251 | input_flags_from_key :: proc(key: rl.KeyboardKey) -> (flags: bit_set[Input_Flag]) { 252 | if rl.IsKeyDown(key) do flags += {.Down} 253 | if rl.IsKeyPressed(key) do flags += {.Pressed} 254 | if rl.IsKeyReleased(key) do flags += {.Released} 255 | return 256 | } 257 | 258 | input_flags_from_mouse_button :: proc(mb: rl.MouseButton) -> (flags: bit_set[Input_Flag]) { 259 | if rl.IsMouseButtonDown(mb) do flags += {.Down} 260 | if rl.IsMouseButtonPressed(mb) do flags += {.Pressed} 261 | if rl.IsMouseButtonReleased(mb) do flags += {.Released} 262 | return 263 | } 264 | 265 | input_clear_temp :: proc(input: ^Input) { 266 | for &action in input.actions { 267 | action -= ~{.Down} // clear all except down flag 268 | } 269 | } 270 | 271 | //////////////////////////////////////////////////////////////////////////////////// 272 | // Game 273 | // 274 | 275 | Game :: struct { 276 | player_pos: rl.Vector2, 277 | player_dir: rl.Vector2, 278 | player_anim_timer: f32, 279 | player_moving: bool, 280 | bullets: [1024]Bullet, 281 | } 282 | 283 | Bullet :: struct { 284 | pos: rl.Vector2, 285 | vel: rl.Vector2, 286 | time: f32, 287 | generation: u32, 288 | } 289 | 290 | game_tick :: proc(game: ^Game, input: Input, delta: f32) { 291 | move_dir: rl.Vector2 292 | if .Down in input.actions[ .Left] do move_dir.x -= 1 293 | if .Down in input.actions[.Right] do move_dir.x += 1 294 | if .Down in input.actions[ .Up] do move_dir.y -= 1 295 | if .Down in input.actions[ .Down] do move_dir.y += 1 296 | 297 | moving := move_dir != 0 298 | 299 | if moving && .Pressed in input.actions[.Dash] { 300 | game.player_pos += move_dir * 120 301 | } 302 | 303 | if moving { 304 | move_dir = linalg.normalize(move_dir) 305 | game.player_dir = move_dir 306 | } 307 | 308 | game.player_pos += move_dir * delta * 150 309 | game.player_anim_timer += delta 310 | game.player_moving = moving 311 | 312 | for &bullet in game.bullets { 313 | if bullet.time == 0 do continue 314 | bullet.pos += bullet.vel * delta 315 | bullet.time += delta 316 | if bullet.time > 0.5 { 317 | bullet.time = 0 318 | } 319 | } 320 | 321 | if .Pressed in input.actions[.Shoot] { 322 | // Insert, this is a bit dumb. This should 323 | for &bullet in game.bullets { 324 | if bullet.time != 0 do continue 325 | bullet.pos = game.player_pos + game.player_dir * 20 326 | bullet.vel = game.player_dir * 1000 327 | bullet.time = 1e-6 328 | bullet.generation += 1 329 | break 330 | } 331 | } 332 | } 333 | 334 | game_draw :: proc(game: Game, tint := rl.WHITE) { 335 | PIXEL_SCALE :: 5 336 | 337 | // Player 338 | { 339 | tex := game.player_moving ? g_player_walk_texture : g_player_idle_texture 340 | 341 | // HACK 342 | dir_row := 0 343 | if game.player_dir.y >= 0 { 344 | if game.player_dir.y > 0.86 { 345 | dir_row = 0 346 | } else { 347 | dir_row = game.player_dir.x > 0 ? 5 : 1 348 | } 349 | } else { 350 | if game.player_dir.y < -0.86 { 351 | dir_row = 3 352 | } else { 353 | dir_row = game.player_dir.x > 0 ? 4 : 2 354 | } 355 | } 356 | 357 | src_rect := rl.Rectangle{ 358 | f32(int(game.player_anim_timer * 10)) * PLAYER_SPRITE_SIZE_X, 359 | f32(dir_row) * PLAYER_SPRITE_SIZE_Y, 360 | PLAYER_SPRITE_SIZE_X, 361 | PLAYER_SPRITE_SIZE_Y, 362 | } 363 | 364 | dst_rect := rl.Rectangle{ 365 | game.player_pos.x, 366 | game.player_pos.y, 367 | PLAYER_SPRITE_SIZE_X * PIXEL_SCALE, 368 | PLAYER_SPRITE_SIZE_Y * PIXEL_SCALE, 369 | } 370 | 371 | rl.DrawTexturePro(tex, src_rect, dst_rect, {dst_rect.width * 0.5, dst_rect.height * 0.5}, 0, tint) 372 | } 373 | 374 | for bullet in game.bullets { 375 | if bullet.time == 0 do continue 376 | 377 | src_rect := rl.Rectangle{ 378 | f32(int(bullet.time * 20)) * BULLET_SPRITE_SIZE_X, 379 | 0, 380 | BULLET_SPRITE_SIZE_X, 381 | BULLET_SPRITE_SIZE_Y, 382 | } 383 | 384 | dst_rect := rl.Rectangle{ 385 | bullet.pos.x, 386 | bullet.pos.y, 387 | BULLET_SPRITE_SIZE_X * PIXEL_SCALE, 388 | BULLET_SPRITE_SIZE_Y * PIXEL_SCALE, 389 | } 390 | 391 | 392 | rl.DrawTexturePro( 393 | g_bullet_texture, 394 | src_rect, 395 | dst_rect, 396 | {dst_rect.width * 0.5, dst_rect.height * 0.5}, 397 | 0, 398 | tint, 399 | ) 400 | } 401 | } 402 | 403 | // This is a separate proc just because of the structure of this example, 404 | // if you wanted interpolation in practice you could inline it into the game_draw proc. 405 | // Assumes 'result' starts out with the same state as 'game' 406 | game_interpolate :: proc(result: ^Game, game, prev_game: Game, alpha: f32) { 407 | result.player_pos = lerp(prev_game.player_pos, game.player_pos, alpha) 408 | result.player_anim_timer = lerp(prev_game.player_anim_timer, game.player_anim_timer, alpha) 409 | 410 | for bullet, i in game.bullets { 411 | prev_bullet := prev_game.bullets[i] 412 | if bullet.time == 0 || prev_bullet.generation != game.bullets[i].generation { 413 | result.bullets[i].time = 0 414 | continue 415 | } 416 | result.bullets[i] = { 417 | pos = lerp(prev_bullet.pos, bullet.pos, alpha), 418 | vel = lerp(prev_bullet.vel, bullet.vel, alpha), 419 | time = lerp(prev_bullet.time, bullet.time, alpha), 420 | generation = bullet.generation, 421 | } 422 | } 423 | } 424 | 425 | lerp :: proc(a, b: $T, t: f32) -> T { 426 | return a * (1 - t) + b * t 427 | } -------------------------------------------------------------------------------- /interpolated.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/interpolated.gif -------------------------------------------------------------------------------- /no_smoothing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/no_smoothing.gif -------------------------------------------------------------------------------- /player_idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/player_idle.png -------------------------------------------------------------------------------- /player_walk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/player_walk.png -------------------------------------------------------------------------------- /reference.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/reference.gif -------------------------------------------------------------------------------- /render_tick.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakubtomsu/fixed-timestep-demo/f84972bd8532f6bb5f8baed56639bde7579629af/render_tick.gif --------------------------------------------------------------------------------