← Will Donnelly

Games in the Source engine have some interesting movement physics which permit tricks like bunnyhopping, airstrafing, and surfing. I implemented a player movement controller for the Godot game engine which can perform the same tricks, and threw that into (the glTF conversion of) an iconic surf map to test it.


Click for HTML5 Surf Demo (surf_utopia_v3)

Movement Controller

The basic concept behind all of the aforementioned movement tricks is that player movement in Source games has a speed limit, but it's enforced only when strafing (so that rocket jumping and the like can fling the player faster than the speed limit) and only in the direction the player is strafing (so that it's possible to strafe sideways a little bit even while flying through the air above the speed cap):

extends KinematicBody

export var jumpImpulse = 2.0
export var gravity = -5.0
export var groundAcceleration = 30.0
export var groundSpeedLimit = 3.0
export var airAcceleration = 500.0
export var airSpeedLimit = 0.5
export var groundFriction = 0.9

export var mouseSensitivity = 0.1

var velocity = Vector3.ZERO

var restartTransform
var restartVelocity

func _ready():
    restartTransform = self.global_transform
    restartVelocity = self.velocity
    pass # Replace with function body.

func _physics_process(delta):
    # Apply gravity, jumping, and ground friction to velocity
    velocity.y += gravity * delta
    if is_on_floor():
        # By using is_action_pressed() rather than is_action_just_pressed()
        # we get automatic bunny hopping.
        if Input.is_action_pressed("move_jump"):
            velocity.y = jumpImpulse
        else:
            velocity *= groundFriction
    
    # Compute X/Z axis strafe vector from WASD inputs
    var basis = $YawAxis/Camera.get_global_transform().basis
    var strafeDir = Vector3(0, 0, 0)
    if Input.is_action_pressed("move_forward"):
        strafeDir -= basis.z
    if Input.is_action_pressed("move_backward"):
        strafeDir += basis.z
    if Input.is_action_pressed("move_left"):
        strafeDir -= basis.x
    if Input.is_action_pressed("move_right"):
        strafeDir += basis.x
    strafeDir.y = 0
    strafeDir = strafeDir.normalized()
    
    # Figure out which strafe force and speed limit applies
    var strafeAccel = groundAcceleration if is_on_floor() else airAcceleration
    var speedLimit = groundSpeedLimit if is_on_floor() else airSpeedLimit
    
    # Project current velocity onto the strafe direction, and compute a capped
    # acceleration such that *projected* speed will remain within the limit.
    var currentSpeed = strafeDir.dot(velocity)
    var accel = strafeAccel * delta
    accel = max(0, min(accel, speedLimit - currentSpeed))
    
    # Apply strafe acceleration to velocity and then integrate motion
    velocity += strafeDir * accel
    velocity = move_and_slide(velocity, Vector3.UP)
    
    if Input.is_action_pressed("move_fast"):
        velocity = Vector3.ZERO
    if Input.is_action_just_released("move_fast"):
        velocity = -30 * basis.z
    
    if Input.is_action_just_pressed("checkpoint"):
        print("Saving Checkpoint: %s / %s" % [self.translation, self.velocity])
        restartTransform = self.global_transform
        restartVelocity = self.velocity	
    
    if Input.is_action_just_pressed("restart"):
        self.global_transform = restartTransform
        self.velocity = restartVelocity
    
    pass

func _input(event):
    if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
        $YawAxis.rotate_x(deg2rad(event.relative.y * mouseSensitivity * -1))
        self.rotate_y(deg2rad(event.relative.x * mouseSensitivity * -1))

        # Clamp yaw to [-89, 89] degrees so you can't flip over
        var yaw = $YawAxis.rotation_degrees.x
        $YawAxis.rotation_degrees.x = clamp(yaw, -89, 89)    

Most of the code is your basic FPS character movement sort of logic. The bit where the magic lives is:

# Project current velocity onto the strafe direction, and compute a capped
# acceleration such that *projected* speed will remain within the limit.
var currentSpeed = strafeDir.dot(velocity)
var accel = strafeAccel * delta
accel = max(0, min(accel, speedLimit - currentSpeed))

Testing and Tweaking

In order to test the player movement code I needed a map. So what I did was I used the io_import_vmf Blender plugin to import surf_utopia_v3, one of the best beginner-friendly surf maps ever made.

I exported the whole thing from Blender to glTF, loaded that up in Godot, and tried surfing on it. Then I realized that there was no collision data.

Luckily this is easily fixed by simply renaming any collidable meshes in a glTF scene to end with -col. Since the Blender VMF import process had only converted the visible geometry and there aren't any visible-but-not-collidable elements in surf_utopia_v3 I was able to bulk-rename the entire thing and re-export.

After that it was just a bit of trial and error to find values for gravity, airAcceleration, and airSpeedLimit for which it was possible to beat the map, and then refine them a bit further until everything felt more or less like it should.

HTML5 Export

Godot has the ability to export games to a variety of platforms, including HTML5 for desktop or mobile browsers. I decided to give this a shot so that I could embed a playable demo in this blog post.

The export process was quite simple. Unfortunately the result doesn't work as well as I'd like -- the game ran at ~200 FPS as a Windows executable during development, but in a web browser it only gets ~30 FPS, with noticeable variation depending on how much of the map is within the view frustum.

I dealt with this using the time-honored technique of lowering the far clipping distance, addding some fog to hide it, and pretending that it's supposed to be aesthetic. This brought the HTML5 export up to playability, but now the same machine can get ~600 FPS running it natively.

So that's a little bit of a disappointment, but for a weekend hack I'm happy with the results.