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.