PVG
5. Entities, Game State, Physics Update — Program Video Games

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)