// Update is called once per frame
    void Update()
    {
        // update ground state
        {
            bool wasOnGround = isOnGround;

            isOnGround   = false;
            groundNormal = Vector3.up;

            float maxGroundingDistance = baseController.skinWidth + GROUND_CHECK_DISTANCE;

            if (Time.time >= jump.ActivatedTime + MIN_JUMP_TIME &&
                Physics.CapsuleCast(ColliderBottom, ColliderTop, baseController.radius, Vector3.down, out RaycastHit hit, maxGroundingDistance, groundCheckLayerMask, QueryTriggerInteraction.Ignore))
            {
                // for use in projecting move direction
                groundNormal = hit.normal;

                bool isGroundFacingUp  = Vector3.Dot(hit.normal, transform.up) > 0f;
                bool isSlopeBelowLimit = Vector3.Angle(hit.normal, transform.up) <= baseController.slopeLimit;
                isOnGround = isGroundFacingUp && isSlopeBelowLimit;

                // snap to ground
                if (isOnGround && hit.distance > baseController.skinWidth)
                {
                    baseController.Move(hit.distance * Vector3.down);
                }
            }

            if (isOnGround && !wasOnGround)
            {
                audioSource.PlayOneShot(hitFloorSound, 3);
            }

            if (isOnGround)
            {
                velocity.y = 0;
            }
        }

        // handle input
        {
            // camera control
            {
                float rx        = playerInput.GetLookInputsHorizontal() * rotationSpeed;
                float ry        = playerInput.GetLookInputsVertical() * rotationSpeed;
                float ryClamped = Mathf.Clamp(playerCamera.transform.localEulerAngles.x - ry, 20, 60);

                transform.Rotate(new Vector3(0f, rx, 0f), Space.Self);
                playerCamera.transform.localEulerAngles = new Vector3(ryClamped, 0f, 0f);
            }

            // movement
            {
                Vector3 inputVelocity  = transform.TransformVector(playerInput.GetMoveInput()) * moveSpeed;
                Vector3 tangent        = Vector3.Cross(inputVelocity.normalized, transform.up);
                Vector3 targetVelocity = Vector3.Cross(groundNormal, tangent).normalized *inputVelocity.magnitude;

                float acceleration   = isOnGround ? maxAcceleration : maxAcceleration * airControlFactor;
                float maxSpeedChange = acceleration * Time.deltaTime;
                float vx             = Mathf.MoveTowards(velocity.x, targetVelocity.x, maxSpeedChange);
                float vy             = Mathf.MoveTowards(velocity.y, targetVelocity.y, maxSpeedChange);
                float vz             = Mathf.MoveTowards(velocity.z, targetVelocity.z, maxSpeedChange);
                velocity = new Vector3(vx, isOnGround ? vy : velocity.y, vz);

                // add jump
                jump.Update(playerInput.GetJumpInputDown());
                if (isOnGround && jump.Activate())
                {
                    velocity.y = jumpSpeed;

                    isOnGround   = false;
                    groundNormal = Vector3.up;
                }

                // apply gravity
                if (!isOnGround)
                {
                    velocity += Vector3.down * gravityAcceleration * Time.deltaTime;
                }

                // collide with other geometry
                Vector3 postVelocity = velocity;
                if (Physics.CapsuleCast(ColliderBottom, ColliderTop, baseController.radius, velocity.normalized, out RaycastHit hit, velocity.magnitude * Time.deltaTime, 0, QueryTriggerInteraction.Ignore))
                {
                    postVelocity = Vector3.ProjectOnPlane(velocity, hit.normal);
                }

                baseController.Move(velocity * Time.deltaTime);
                velocity = postVelocity;
            }

            // pickup
            {
                pickup1.Update(playerInput.GetFireInputDown());
                if (pickup1.Activate())
                {
                    Pickup(0);
                }
                pickup2.Update(playerInput.GetFire2InputDown());
                if (pickup2.Activate())
                {
                    Pickup(1);
                }
            }
        }
    }