5. Entities, Game State, Physics Update
This game is designed to be a single-player experience.
As such, we can get away with having our physics update and drawing in the same loop.
Many multiplayer or competitive games will separate physics and drawing so that lag doesn't cause the game's physics to slow down.
Note that even popular games like Dark Souls and Skyrim have physics tied to the frame rate.
I will cover how to decouple the physics from the frame rate as an optional addition later on.
First, set our target FPS so we have the same experience across devices.
rl.InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, "Program Video Games!")
rl.SetTargetFPS(60)
Before we implement the physics, it's time to add our entities.
This sets us up to have a single call for physics updates when we add enemies.
Game_State and Player -> Entity
We're going to change our Player struct into an Entity.
Entity :: struct {
using collider: Rect,
vel: Vec2,
move_speed: f32,
jump_force: f32,
is_grounded: bool,
is_dead: bool, // New field
}
We've added a field called is_dead. When an entities "dies", we set this to true and then we can re-use the entity.
We can do that because we are going to store our entities in a dynamic array.
Game_State :: struct {
camera: rl.Camera2D,
entities: [dynamic]Entity,
solid_tiles: [dynamic]rl.Rectangle,
}
We've also moved our camera and tiles into this Game_State struct.
Declaring a global gs: Game_State gives us access to the game state from anywhere. Though we will still pass in values to procedures for clarity's sake.
So, anywhere we have used camera or solid_tiles, we want to now use gs.camera or gs.solid_tiles.
We also don't need to create the solid_tiles variable before loading the level as that's in Game_State.
gs.camera = rl.Camera2D {
zoom = ZOOM,
}
// when loading the level data
if v == '#' {
append(&gs.solid_tiles, rl.Rectangle{x, y, TILE_SIZE, TILE_SIZE})
}
// in the drawing loop
rl.BeginMode2D(gs.camera)
rl.ClearBackground(BG_COLOR)
for rect in gs.solid_tiles {
We also want to move our player variable code and use an Entity.
Before we can do that, we'll need to set up the entity system.
Entity System
Create a new file src/entity.odin
The entity system we are going to use is dead simple.
It's sometimes called a "Mega Struct" system.
The idea is every Entity is the same type. The things that differentiate them are which fields are set to what values. We'll cover this more in the enemies and behaviour section.
For now, we know that our player is just another Entity.
Here is the entire entity system as it stands:
package main
entity_create :: proc(entity: Entity) -> int {
for &e, i in gs.entities {
if e.is_dead {
e = entity
e.is_dead = false
return i
}
}
index := len(gs.entities)
append(&gs.entities, entity)
return index
}
entity_get :: proc(id: int) -> ^Entity {
return &gs.entities[id]
}
In Odin, you can access a pointer to the current element by inserting an ampersand before the name: &e. If you don't do this, the element is read-only.
entity_create - Loop through every entity and as soon as we find one that is_dead, set the fields, mark it as alive, and return the index.
We use the array index as an ID. That gives us a memory safe way to refer to entities. If the entity array is expanded and the base address changes, it's fine because we don't use a direct pointer.
entity_get - A simple wrapper that gets a pointer. It is unsafe to save this across time where an entity_create is called.
Okay, now that we have an entity system, we can fill in a few missing pieces.
Here's the level loading code with comments marking changes:
// Player ID for use later
player_id: int
{
level_data, ok := os.read_entire_file("data/simple_level.dat")
assert(ok, "Failed to load level data")
x, y: f32
for v in level_data {
if v == '
' {
y += TILE_SIZE
x = 0
continue
}
if v == '#' {
append(&gs.solid_tiles, rl.Rectangle{x, y, TILE_SIZE, TILE_SIZE})
}
// Check for 'P' which will be the player's starting position
if v == 'P' {
// Create the player Entity
player_id = entity_create(
{x = x, y = y, width = 16, height = 38, move_speed = 280, jump_force = 650},
)
}
x += TILE_SIZE
}
}
Double check the player's move_speed and jump_force as they'll be important later.
Physics Update
Now that we have our entities and level geometry all stored in dynamic arrays, we can create our physics update procedure.
Create a new file: src/physics.odin
Here's the entire file with added comments for explanation.
package main
import rl "vendor:raylib"
// The first two parameters take a slice []T
// Slices are equivalent to: Pointer + Length
// The last parameter is the Delta Time
physics_update :: proc(entities: []Entity, static_colliders: []Rect, dt: f32) {
// Iterate through all the entities in the array
for &entity in entities {
// Skip dead entities
if entity.is_dead do continue
// Iterate multiple times for a more stable experience
// Also helps prevent 'tunneling' through thin walls at high speeds
for _ in 0 ..< PHYSICS_ITERATIONS {
step := dt / PHYSICS_ITERATIONS
entity.vel.y += GRAVITY
if entity.vel.y > TERMINAL_VELOCITY {
entity.vel.y = TERMINAL_VELOCITY
}
// Updating the axes separately reduces edge cases
// When updating both .x and .y at the same time, the colliders get
// stuck or go through walls easily
// It also allows us to easily separate concerns, such as setting
// is_grounded when hitting the ground
// Y axis
entity.y += entity.vel.y * step
entity.is_grounded = false
for static in static_colliders {
// This checks if two rectangles overlap
// Like the rectangle marked X below
// +------+
// | |
// | |
// | +---+-+
// | | X | |
// +--+---+ |
// +-----+
if rl.CheckCollisionRecs(entity.collider, static) {
if entity.vel.y > 0 {
// Moving rectangle is above static
// Note that with high enough velocity, this may not
// be true
// That's why we use multiple iterations
entity.y = static.y - entity.height
entity.is_grounded = true
} else {
entity.y = static.y + static.height
}
entity.vel.y = 0
break
}
}
// X axis
entity.x += entity.vel.x * step
for static in static_colliders {
if rl.CheckCollisionRecs(entity.collider, static) {
if entity.vel.x > 0 {
// Moving rectangle is left of static
entity.x = static.x - entity.width
} else {
entity.x = static.x + static.width
}
entity.vel.x = 0
break
}
}
}
}
}
Updating the Level
After some testing, jumping 5-tiles up and 10-tiles sideways is a good rule of thumb.
We can tweak the numbers to make that the case.
// in our constants
PHYSICS_ITERATIONS :: 8
GRAVITY :: 5
TERMINAL_VELOCITY :: 1200
This will allow us to create intentionally designed sections with unreachable areas until a power-up is retrieved as is common in Metroidvania games.
To test the new constraints, we update the level:
########################################
# #
# #
# #
# #
# #
# #
# # #
# # #
# # #
# # #
# P # # ## #
# # # #
# ########## # #
# # #
# # #
# ######## #
# #
# #
# ### #
# ### #
########################################
########################################
Our player can jump over the wall on the right, but not the one on the left.
The player can jump from the top of the right wall onto the small platform (though it's a bit hard).
It will be easier to jump the gap when we introduce "Coyote Time".
Coyote Time allows the player to jump for a short time after leaving a platform. Named after Wile E. Coyote, a cartoon character who was often depicted as only falling after noticing he was mid-air.
Input and Putting it all Together
Finally, we need to update the input section in our main loop and call the new update_physics procedure.
Here is the entire main loop:
for !rl.WindowShouldClose() {
dt := rl.GetFrameTime()
// Make sure to get a new pointer every frame in case the entities
// array is moved in memory
player := entity_get(player_id)
input_x: f32
if rl.IsKeyDown(.T) do input_x += 1
if rl.IsKeyDown(.R) do input_x -= 1
if rl.IsKeyPressed(.SPACE) && player.is_grounded {
player.vel.y = -player.jump_force
player.is_grounded = false
}
player.vel.x = input_x * player.move_speed
// [:] grabs a slice of the dynamic arrays
physics_update(gs.entities[:], gs.solid_tiles[:], dt)
rl.BeginDrawing()
rl.BeginMode2D(gs.camera)
rl.ClearBackground(BG_COLOR)
for rect in gs.solid_tiles {
rl.DrawRectangleRec(rect, rl.WHITE)
rl.DrawRectangleLinesEx(rect, 1, rl.GRAY)
}
rl.DrawRectangleLinesEx(player.collider, 1, rl.GREEN)
rl.EndMode2D()
rl.EndDrawing()
}
Okay, now we have a pretty good start on a character controller.
We are set up to start adding other entity types, such as enemies.
As the code is now split into separate files, I've attached a zip.
(zip goes here)