S&box Tutorial - How to Create a Player Controller

This is a written version of the following youtube tutorial:

Create an Empty GameObject named “Player” and place it wherever you want the player to start. You can choose to convert this to a prefab at any point if you’d like, but for the sake of the tutorial I’m going to do everything in one scene.

On the newly created GameObject I’m going to add the “player” tag and add a CharacterController component which ignores both the “player” layer, and the “trigger” layer. This means if we add additional colliders to the player, we won’t get stuck in ourselves, and it also means we can create colliders marked with the “trigger” tag in our world and the player will be able to walk through it.

This GameObject is going to be the root of our Player, so let’s add an Empty GameObject as a child and call it “Body”. This object will be the visual representation of our player. For this tutorial, I’m going to be using the Citizen as my playermodel, but you can choose any model you’d like (or even a capsule for that matter). To do this I’ve added a Skinned Model Renderer component to the Body GameObject and have also added a Citizen Animation Helper for when we get around to plugging animation in towards the end.

Before we start writing any code, we need another Empty GameObject named “Head” which should be placed roughly at the height of your character’s head (the height will vary depending on your player’s model). This should be a child of the root Player object. I’m also going to add a Camera GameObject as a child to our root Player object which can positioned wherever you’d like for now (since it will be controlled by a script later).

Now I’m going to create a new script on the root Player object called PlayerMovement. Since I’m creating the script here it will be attached to the GameObject automatically. We’re going to want to add a few properties to our script so we can tweak the player’s movement in the inspector. For this controller we’re going to need the following:

    // Movement Properties
    [Property] public float GroundControl { get; set; } = 4.0f;
	[Property] public float AirControl { get; set; } = 0.1f;
	[Property] public float MaxForce { get; set; } = 50f;
	[Property] public float Speed { get; set; } = 160f;
	[Property] public float RunSpeed { get; set; } = 290f;
	[Property] public float WalkSpeed { get; set; } = 90f;
	[Property] public float JumpForce { get; set; } = 400f;

Then we’ll also want some properties so we can easily reference other GameObjects like the Head and Body of the player.

    // Object References
    [Property] public GameObject Head { get; set; }
    [Property] public GameObject Body { get; set; }

Finally we’ll need two public member variables for crouching and sprinting and we’ll also need 2 private member variable so we can cache both the CharacterController component and CitizenAnimationHelper component. This could also be done through a property but I prefer this method since I know that the CharacterController will always be attached to the player. The CitizenAnimationHelper component is only required if you’re using the Citizen model.

    // Required for CitizenAnimationHelper
    using Sandbox.Citizen;

    // Member Variables
    public bool IsCrouching = false;
    public bool IsSprinting = false;
    private CharacterController characterController;
    private CitizenAnimationHelper animationHelper;

    protected override void OnAwake()
    {
        characterController = Components.Get<CharacterController>();
        animationHelper = Components.Get<CitizenAnimationHelper>();
    }

Now a good place to start is with our WishVelocity, which is the velocity the player wishes to be moving in the current frame. This is where we’re going to be doing most of our input checks.

    // Member Variables
    Vector3 WishVelocity = Vector3.zero;
    // ...

    void BuildWishVelocity()
    {
        WishVelocity = 0;

        var rot = Head.Transform.Rotation;
		if ( Input.Down( "Forward" ) ) WishVelocity += rot.Forward;
		if ( Input.Down( "Backward" ) ) WishVelocity += rot.Backward;
		if ( Input.Down( "Left" ) ) WishVelocity += rot.Left;
		if ( Input.Down( "Right" ) ) WishVelocity += rot.Right;

        WishVelocity = WishVelocity.WithZ( 0 );

        if ( !WishVelocity.IsNearZeroLength ) WishVelocity = WishVelocity.Normal;

        if(IsCrouching) WishVelocity *= CrouchSpeed; // Crouching takes presedence over sprinting
        else if(IsSprinting) WishVelocity *= RunSpeed; // Sprinting takes presedence over walking
        else WishVelocity *= Speed;
    }

Let’s see if we can get this guy moving. We’ll make a Move function that will cause move the CharacterController based on the current WishVelocity.

   void Move()
	{
		// Get gravity from our scene
		var gravity = Scene.PhysicsWorld.Gravity;

		if ( characterController.IsOnGround )
		{
			// Apply Friction/Acceleration
			characterController.Velocity = characterController.Velocity.WithZ( 0 );
			characterController.Accelerate( WishVelocity );
			characterController.ApplyFriction( GroundControl );
		}
		else
		{
			// Apply Air Control / Gravity
			characterController.Velocity += gravity * Time.Delta * 0.5f;
			characterController.Accelerate( WishVelocity.ClampLength( MaxForce ) );
			characterController.ApplyFriction( AirControl );
		}

		// Move the character controller
		characterController.Move();

		// Apply the second half of gravity after movement
		if ( !characterController.IsOnGround )
		{
			characterController.Velocity += gravity * Time.Delta * 0.5f;
		}
		else
		{
			characterController.Velocity = characterController.Velocity.WithZ( 0 );
		}
	}

To see what we’ve built so-far we need to make an OnUpdate function and an OnFixedUpdate function to set some variables and call our functions. OnUpdate is called each frame, while OnFixedUpdate is called each physics tick. You can change this update rate by clicking on the root of your scene and looking at the inspector.

    protected override void OnUpdate()
    {
        // Set our sprinting and crouching states
        IsCrouching = Input.Down("Crouch");
        IsSprinting = Input.Down("Run");
    }

    protected override void OnFixedUpdate()
    {
        BuildWishVelocity();
        Move();
    }

Now we can see our player is able to move around the scene, but we are not able to look around in any other direction. So lets go over to the Camera GameObject we created earlier and add a new script called CameraMovement. Let’s start by setting up the necessary properties/variables:

    // Properties
    [Property] public PlayerController Player { get; set; }
    [Property] public GameObject Body { get; set; }
    [Property] public GameObject Head { get; set; }
    [Property] public float Distance { get; set; } = 0f;

    // Variables
    public bool IsFirstPerson => Distance == 0f; // Helpful but not required. You could always just check if Distance == 0f
    private CameraController Camera;
    private ModelRenderer BodyRenderer;

    protected override void OnAwake()
    {
        Camera = Components.Get<CameraController>();
        BodyRenderer = Body.Components.Get<ModelRenderer>();
    }

In the OnUpdate function of the script we’re going to check for mouse movement and rotate the head accordingly. Then we will set the position of the camera based on the distance we’ve set in the inspector. If we’re in first person we’ll also need to do something to our Body so we cannot see it in a first person view. I recommend setting CastShadows to ShadowsOnly on your ModelRenderer component since that will still allow you to see the shadow of your character.

    protected override void OnUpdate()
    {
        // Rotate the head based on mouse movement
        var eyeAngles = Head.Transform.Rotation.Angles();
        eyeAngles.pitch += Input.MouseDelta.y * 0.1f;
        eyeAngles.yaw -= Input.MouseDelta.x * 0.1f;
        eyeAngles.roll = 0f;
        eyeAngles.pitch = eyeAngles.pitch.Clamp( -89.9f, 89.9f ); // So we don't break our necks
        Head.Transform.Rotation = eyeAngles.ToRotation();

        // Set the position of the camera
        if(Camera is not null)
        {
            var camPos = Head.Transform.Position;
            if(!IsFirstPerson)
            {
                // Perform a trace backwards to see where we can safely place the camera
                var camForward = eyeAngles.ToRotation().Forward;
                var camTrace = Scene.Trace.Ray(camPos, camPos - (camForward * Distance))
                    .WithoutTags("player", "trigger")
                    .Run();
                if(camTrace.Hit)
                {
                    camPos = camTrace.HitPosition + camTrace.HitNormal;
                }
                else
                {
                    camPos = camTrace.EndPosition;
                }

                // Show the body if we're not in first person
                BodyRenderer.RenderType = On;
            }
            else
            {
                // Hide the body if we're in first person
                BodyRenderer.RenderType = ShadowsOnly;
            }

            // Set the position of the camera to our calculated position
            Camera.Transform.Position = camPos;
            Camera.Transform.Rotation = eyeAngles.ToRotation();
        }
    }

Now we see we can successfully move and look around. We can also play with the “Distance” value in our CameraMovement script to make the camera third-person at any moment. We can also add a Range attribute to the Distance property so it cannot go below zero and cannot go beyond, say, 1000 units.

    [Property, Range(0f, 1000f)] public float Distance { get; set; } = 0f;

Now we’ll make sure the character is always facing the correct direction. Let’s go back to the PlayerMovement script and add a new function called RotateBody which will rotate the body based on the direction we’re moving.

    void RotateBody()
    {
        if(Body is null) return;

        var targetAngle = new Angles(0, Head.Transform.Rotation.Yaw(), 0).ToRotation();
        float rotateDifference = Body.Transform.Rotation.Distance(targetAngle);

        // Lerp body rotation if we're moving or rotating far enough
        if(rotateDifference > 50f || characterController.Velocity.Length > 10f)
        {
            Body.Transform.Rotation = Rotation.Lerp(bodyRotation, targetAngle, Time.Delta * 2f);
        }
    }

    // Then make sure to add RotateBody() somewhere in our OnUpdate function

Our character looks stunning but he’s a little too grounded. Let’s give him a jump. We’ll add a new function called Jump which will cause the character to jump if he is able to. Then we will call the function in OnUpdate when the player pressed the jump key.

    void Jump()
    {
        if(!characterController.IsOnGround) return;

        characterController.Punch(Vector3.Up * JumpForce);
        animationHelper?.TriggerJump(); // Trigger our jump animation if we have one
    }

    protected override void OnUpdate()
    {
        // ...
        if(Input.Pressed("Jump")) Jump();
    }

Now that our character is nearly feature-complete, let’s get him animated. We’ll make one last function in our PlayerMovement script called UpdateAnimation which will update the animations based on all the information we’ve gathered so far. Then we’ll call this function at the end of our OnUpdate function.

    void UpdateAnimation()
    {
        if(animationHelper is null) return;

        animationHelper.WithWishVelocity(WishVelocity);
        animationHelper.WithVelocity(characterController.Velocity);
        animationHelper.AimAngle = Head.Transform.Rotation;
        animationHelper.IsGrounded = characterController.IsOnGround;
        animationHelper.WithLook(Head.Transform.Rotation.Forward, 1, 0.75f, 0.5f);
        animationHelper.MoveStyle = CitizenMoveStyle.MoveStyles.Run;
        animationHelper.DuckLevel = IsCrouching ? 1f : 0f;
    }

    // Then make sure to add UpdateAnimation() at the end of our OnUpdate function

You’ll notice now that when we crouch we aren’t actually able to fit through small spaces, and the camera doesn’t move with us when we crouch. Let’s fix that. We’ll add a new function called UpdateCrouch which will replace our current crouch check in OnUpdate.

    void UpdateCrouch()
    {
        if(characterController is null) return;

        if(Input.Pressed("Crouch") && !IsCrouching)
        {
            IsCrouching = true;
            characterController.Height /= 2f; // Reduce the height of our character controller
        }

        if(Input.Released("Crouch") && IsCrouching)
        {
            IsCrouching = false;
            characterController.Height *= 2f; // Return the height of our character controller to normal
        }
    }

    // Then make sure to add UpdateCrouch() in place of our current crouch check in OnUpdate

Now for the camera, we just need to add a CurrentOffset variable which will be the positional offset of the camera from it’s calculated position. We will then lerp that position downward to our crouch height when the player is crouching. So we can make the following changes to our CameraMovement script:

    // Variables
    // ...
    Vector3 CurrentOffset = Vector3.zero;

    protected override void OnUpdate()
    {
        // Rotate the head based on mouse movement
        // ..

        // Set the current offset
        var targetOffset = Vector3.Zero;
        if(Player.IsCrouching) targetOffset += Vector3.Down * 32f;
        CurrentOffset = Vector3.Lerp(CurrentOffset, targetOffset, Time.Delta * 10f);

        // Set the position of the camera
        // ..
            var camPos = Head.Transform.Position + CurrentOffset;
        // ..
    }

The camera should now smoothly move up/down with us as we crouch and uncrouch. And you’ll see that I can now fit under this small gap in the wall.

Hope this helps you get started with a basic player controller that you can build upon for your own game. On screen I have some footage of something I was able to create with this same system, so expect some future tutorials that show you how to add certain mechanics to the player controller. If you have any questions, feel free to leave a comment on the youtube video or join the S&box Discord.

Written on December 28, 2023