PVG
19. Walkable Surfaces — Program Video Games

19. Walkable Surfaces

In many RPGs, characters don't jump or fall - they simply walk along surfaces. Think of classic JRPGs where characters smoothly traverse ramps and stairs without any physics simulation. Today we're implementing this ground-following movement system where entities automatically stick to walkable surfaces.

The Fractal Code Cycle Applied to Ground Movement

Recall our fundamental pattern:

Input → Processing → Output

For ground-following movement:

  • Input: Player velocity, current position, walkable surface geometry
  • Processing: Project movement onto surfaces, sample ground height, validate slopes
  • Output: Player position that follows terrain contours

The Design Choice: No Gravity

Our RPG uses a deliberate design choice: characters always walk on surfaces. They don't jump, they don't fall, they simply move along the ground. This simplifies many aspects:

  • No need for gravity simulation
  • No complex physics states (falling, jumping, landing)
  • Predictable, controllable movement
  • Classic RPG feel

Separating World Geometry

We divide our world geometry into two distinct categories:

physics: struct {
    static_geometry: [dynamic]OBB3,      // Walls, etc
    walkable_surfaces: [dynamic]Quad,    // Floors, ramps to walk on
},

The static_geometry pushes entities away (collision response), while walkable_surfaces pull entities down to their surface (ground following).

The Quad: A Walkable Surface

A Quad represents a rectangular surface that can be walked on:

Quad :: struct {
    center: Vec3,
    rotation: Vec3,    // Can be tilted to create ramps
    size: Vec2,        // Width and depth - no height
}

Unlike OBB3s which are volumes, Quads are surfaces - perfect for representing floors, ramps, and platforms.

The Ground Sampling Algorithm

The core of our system is sampling the ground beneath where the player wants to move:

physics_calculate_next_position :: proc(center, velocity: Vec3, radius: f32) -> Vec3 {
    result := center
    MAX_STEP_HEIGHT :: 0.3
    
    if velocity != 0 {
        // Calculate where we want to move horizontally
        horizontal_target := center + velocity
        
        // Start sampling from above the target
        start := horizontal_target + Vec3{0, radius, 0}
        sample_dist := radius * 2 + MAX_STEP_HEIGHT
        
        // Find the ground at that position
        if hit, hit_normal, ok := sample_ground_height(start, sample_dist); ok {
            // Check if the slope is walkable
            cos_angle := abs(rl.Vector3DotProduct(hit_normal, Vec3{0, 1, 0}))
            slope_angle := math.acos(cos_angle)
            MAX_SLOPE :: math.PI / 4  // 45 degrees
            
            if slope_angle <= MAX_SLOPE {
                // Snap to the surface
                result = hit
                result.y = hit.y + radius
            }
        }
        
        // Still apply wall collisions
        result = do_obb_collisions_xy(result, radius, gs.physics.static_geometry[:])
        result = do_obb_collisions_xz(result, radius, gs.physics.static_geometry[:])
        result = do_obb_collisions_zy(result, radius, gs.physics.static_geometry[:])
    }
    
    return result
}

How Ground Sampling Works

The sampling process converts each Quad into a mathematical plane and finds intersection points:

Step 1: Create a Plane from the Quad

// Three points define a plane
local_a := Vec3{+half.x, 0, +half.y}  // Top-right
local_b := Vec3{-half.x, 0, +half.y}  // Top-left
local_c := Vec3{-half.x, 0, -half.y}  // Bottom-left

// Convert to world space
a := world_from_local(local_a, quad.center, quad.rotation)
b := world_from_local(local_b, quad.center, quad.rotation)
c := world_from_local(local_c, quad.center, quad.rotation)

// Calculate plane equation
plane := compute_plane(a, b, c)

Step 2: Cast a Ray Downward

We shoot a ray from above the player's position straight down:

offset := Vec3{0, -1, 0} * max_dist
if _, q, plane_ok := check_segment_plane(pos, pos + offset, plane); plane_ok {
    // q is where the ray hits the plane
}

Step 3: Check Boundaries

The ray might hit the infinite plane, but we only care if it's within the Quad's bounds:

// Transform hit point to Quad's local space
local_q := local_from_world(q, quad.center, quad.rotation)

// Check if within rectangular bounds
if local_q.x >= -half.x && local_q.x <= half.x &&
   local_q.z >= -half.y && local_q.z <= half.y {
    // This is valid ground!
}

The Plane Mathematics

The plane equation uses the dot product to determine intersections:

compute_plane :: proc(a, b, c: Vec3) -> Plane {
    p: Plane
    // Normal vector perpendicular to the plane
    p.normal = rl.Vector3Normalize(rl.Vector3CrossProduct(b - a, c - a))
    // Distance from origin
    p.d = rl.Vector3DotProduct(p.normal, a)
    return p
}

check_segment_plane :: proc(a, b: Vec3, p: Plane) -> (t: f32, q: Vec3, ok: bool) {
    ab := b - a
    // Calculate where along the segment we hit the plane
    t = ((p.d - rl.Vector3DotProduct(p.normal, a)) / rl.Vector3DotProduct(p.normal, ab)).x
    
    if t >= 0 && t <= 1 {  // Within segment bounds
        q = a + t * ab     // Intersection point
        ok = true
    }
    
    return
}

Slope Validation

Not all surfaces should be walkable. We limit the maximum slope angle:

MAX_SLOPE :: math.PI / 4  // 45 degrees

cos_angle := abs(rl.Vector3DotProduct(hit_normal, Vec3{0, 1, 0}))
slope_angle := math.acos(cos_angle)

if slope_angle <= MAX_SLOPE {
    // Surface is walkable
}

This prevents players from walking up walls or overly steep surfaces.

Velocity-Based Movement

We've switched from direct position updates to velocity:

Entity :: struct {
    position: Vec3,
    velocity: Vec3,  // Movement intention per frame
    // ...
}

This separation makes the code cleaner - input sets velocity, physics applies it.

Visualizing Walkable Surfaces

The draw_quad function helps us see walkable surfaces:

draw_quad :: proc(quad: Quad, color := rl.WHITE, cross_color := rl.DARKGRAY) {
    half_size := quad.size / 2
    corners := [4]Vec3{
        {-half_size.x, 0, -half_size.y},
        { half_size.x, 0, -half_size.y},
        {-half_size.x, 0,  half_size.y},
        { half_size.x, 0,  half_size.y},
    }

    rotation_matrix := rl.MatrixRotateXYZ(quad.rotation)

    for &corner, i in corners {
        rotated_corner := rl.Vector3Transform(corner, rotation_matrix)
        corners[i] = rotated_corner + quad.center
    }

    rl.DrawLine3D(corners[0], corners[2], color)
    rl.DrawLine3D(corners[2], corners[3], color)
    rl.DrawLine3D(corners[3], corners[1], color)
    rl.DrawLine3D(corners[1], corners[0], color)

    rl.DrawLine3D(corners[0], corners[3], cross_color)
    rl.DrawLine3D(corners[1], corners[2], cross_color)
}

The diagonal lines make it easy to see which way a ramp is facing.

Step Height

The MAX_STEP_HEIGHT constant allows characters to step up small obstacles:

MAX_STEP_HEIGHT :: 0.3
sample_dist := radius * 2 + MAX_STEP_HEIGHT

This means if there's a small step or curb, the character will automatically step up onto it rather than being blocked.

Testing with a Ramp

The code creates a simple test environment:

// Flat ground at y=0
append(&gs.physics.walkable_surfaces, Quad{
    center = Vec3{0, 0, 0},
    rotation = Vec3{0, 0, 0},
    size = Vec2{10, 10},
})

// 45-degree ramp
append(&gs.physics.walkable_surfaces, Quad{
    center = Vec3{0, -3.39, 8.18},
    rotation = Vec3{math.to_radians_f32(45), 0, 0},
    size = Vec2{10, 10},
})

The character smoothly transitions from flat ground to the ramp and back.

The Big Idea

By treating movement as "walking on surfaces" rather than "moving through 3D space", we've created a movement system that feels natural for an RPG. Characters stick to the ground, smoothly traverse ramps, and can't walk up walls.

This approach:

  • Eliminates floating or falling bugs
  • Provides predictable, stable movement
  • Matches player expectations from classic RPGs
  • Keeps the code simple and maintainable

The key insight is that not every game needs general physics - sometimes a specialised solution that perfectly fits your game's needs is the better choice.