├── .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
--------------------------------------------------------------------------------