Skip to content

kids.kapish.characters.controllers

unity

Character Controller systems (Core + FirstPerson) with Runtime and Editor assemblies.

Architecture

The package provides a modular first-person character controller built on Unity's CharacterController. The design separates concerns into discrete FirstPersonModule subclasses that are discovered, sorted, and dispatched by a central FirstPersonRig component.

Module System

FirstPersonRig is the orchestrator. On Awake(), it:

  1. Ensures a CharacterController with sane defaults (radius 0.3, 45-degree slope, Player layer).
  2. Auto-builds the camera hierarchy: CameraPivot > CameraPitchOffset > CameraBob.
  3. Discovers and caches all FirstPersonModule components in its children.
  4. Initializes modules in slot order, then builds per-phase sorted lists.

Each frame, the rig:

  1. Resets frameVelocity to zero.
  2. Calls OnModuleUpdate(dt) on all update-phase modules in order. Modules contribute velocity via Rig.AddVelocity().
  3. Applies accumulated frameVelocity * dt via CharacterController.Move().
  4. Calls OnModuleFixedUpdate(dt) and OnModuleLateUpdate(dt) on their respective lists.

Execution Order

Module order is controlled by [ModuleOrder] attribute on the class. Each phase (Update, Fixed, Late) has its own order value. Within the same order, explicit slot index breaks ties, then alphabetical name.

Current default order (Update phase):

OrderModuleRole
-100FirstPersonCameraStackCamera setup
-90FirstPersonGroundingGround detection
-90FirstPersonReticleReticle origin
-60FirstPersonCapsuleCapsule dimensions
-55FirstPersonCrouchCrouch input
-50FirstPersonSprintSprint input
-20FirstPersonJumpJump input
-20FirstPersonInteractionInteraction detection
-19FirstPersonTargetingLong-range targeting
-15FirstPersonGravityGravity + vertical velocity
-10FirstPersonLookMouse look
-5FirstPersonMotorMovement calculation
50FirstPersonAudioSourceAudio
100FirstPersonCinematicLookCinematic override

Velocity Accumulation

Rather than each module calling CharacterController.Move() independently, all modules contribute to a shared frameVelocity vector via AddVelocity(). The rig applies a single Move() call at the end of Update. This prevents order-dependent movement bugs and makes it trivial to add external forces (AddExternalForce()).

SetVerticalVelocity() overrides only the Y component, useful for instant impulses like jumps.

Input Abstraction

IFirstPersonInputSource defines the input contract: Move, Look, JumpDown, SprintHeld, CrouchHeld, CrouchToggleDown, InteractDown. The default FirstPersonInputAdapter wraps Unity Input System InputActionReference fields.

Edge-press inputs (JumpDown, CrouchToggleDown, InteractDown) support optional fixed-safety buffering: presses are held for a configurable window (default 75ms) so FixedUpdate modules don't miss frame-boundary inputs.

Capsule and Crouching

FirstPersonCapsule owns the physical dimensions. It drives CharacterController.height, center, and radius, and positions the camera pivot Y to match eye height. Height transitions are smooth (MoveTowards at configurable speed).

Standing is gated by CanFitHeight() -- a capsule overlap test. If blocked, GetMaxClearHeight() binary-searches the tallest clear height to allow partial uncrouching under low ceilings.

Ground Detection

FirstPersonGrounding combines CharacterController.isGrounded (primary) with a SphereCast for detailed surface data (normal, point, collider). It exposes:

  • IsGrounded -- touching the ground.
  • IsWalkable -- slope angle within limit.
  • IsSupported -- grounded AND walkable (the common "can I stand/walk" check).
  • Landed / LeftGround signals (both parameterless Signal).

Interaction System

Two layers of interaction:

  1. Arms-reach (FirstPersonInteraction) -- SphereCast forward from the reticle origin. Maintains a priority-sorted list of IPlayerInteractable candidates scored by distance and center-screen alignment. Handles hover state and interact dispatch.

  2. Long-range targeting (FirstPersonTargeting) -- Raycast forward. Tracks IPlayerTargetable enter/exit. Optionally tracks IPlayerDiscoverable objects requiring sustained focused gaze (configurable duration and dot threshold) before triggering discovery.

Cinematic Look

FirstPersonCinematicLook smoothly overrides the camera to track a target or look in a direction, with optional FOV zoom via additive FOV on the camera stack. Disables FirstPersonLook when active. Call ReturnControl() to hand back to the player.

Camera Stack

FirstPersonCameraStack manages two cameras under the CameraBob anchor:

  • World Camera -- standard rendering, tagged MainCamera.
  • Hands Camera -- depth-only clear, higher depth, separate ViewModel layer culling.

FOV is smoothed with framerate-independent exponential interpolation. Modules can add FOV offsets via SetAdditiveFov(owner, delta).

Editor Tooling

  • FirstPersonRigEditor extends CarrotBehaviourInspector with a custom module management panel. Core modules are managed by grouped inspector fields; custom modules get an add/remove dropdown.
  • FirstPersonDebugDrawGizmos draws capsule wireframes, foot markers, ground normal arrows, and look direction cones in the Scene view.

Key Design Decisions

  • Velocity accumulation over direct Move calls. Single CharacterController.Move() per frame eliminates order-dependent movement artifacts.
  • Modules are MonoBehaviours. Each module is a component on a child GameObject, visible in the hierarchy. This makes debugging and per-module disable/enable trivial.
  • Auto-build hierarchy. FirstPersonRig creates the camera pivot chain and module GameObjects automatically on Reset/Awake, reducing manual setup.
  • Attribute-driven execution order. [ModuleOrder] keeps ordering declarative and co-located with the module implementation.
  • Interface-based interaction. IPlayerInteractable, IPlayerTargetable, and IPlayerDiscoverable are all interfaces, not base classes. Any MonoBehaviour can implement them.

Usage Guide

Character Controller systems with a modular first-person rig built on Unity's CharacterController.

Setup

  1. Add kids.kapish.characters.controllers and its dependencies (kids.kapish, kids.kapish.input) to your Unity project.
  2. Create a Player layer in Project Settings > Tags and Layers (the rig auto-assigns itself to this layer).
  3. Ensure the Unity Input System package is installed and your project uses the new Input System backend.

Common Patterns

1. Create a first-person rig

Add FirstPersonRig to an empty GameObject. With autoBuildHierarchy enabled (the default), it automatically creates:

Player (FirstPersonRig + CharacterController)
├── CameraPivot
│   └── CameraPitchOffset
│       └── CameraBob
│           ├── CameraMain (Camera)
│           └── CameraHands (Camera)
├── Input (FirstPersonInputAdapter)
└── Modules
    ├── M: FirstPersonCapsule
    ├── M: FirstPersonLook
    ├── M: FirstPersonMotor
    ├── M: FirstPersonCameraStack
    ├── M: FirstPersonGrounding
    ├── M: FirstPersonReticle
    ├── M: FirstPersonAudioSource
    ├── M: FirstPersonInteraction
    ├── M: FirstPersonCrouch
    ├── M: FirstPersonSprint
    └── M: FirstPersonGravity

2. Configure input

Wire up InputActionReference fields on the FirstPersonInputAdapter component under the Input child:

  • Move -- Vector2 (WASD / left stick)
  • Look -- Vector2 (mouse delta / right stick)
  • Jump -- Button
  • Sprint -- Button
  • Crouch -- Button
  • Interact -- Button

3. Access modules at runtime

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Physical;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Core;

FirstPersonRig rig = GetComponent<FirstPersonRig>();

// Get a built-in module
FirstPersonGrounding grounding = rig.GetModule<FirstPersonGrounding>();
if (grounding.IsSupported)
{
    Debug.Log("On solid ground");
}

// Access cameras
Camera worldCam = rig.WorldCamera;
Camera handsCam = rig.HandsCamera;

4. Add custom modules

Create a module by extending FirstPersonModule:

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime.Modules;
using Framework.CarrotControllers.Core.Attributes;

[ModuleOrder(UpdateOrder = 10)]
public class MyCustomModule : FirstPersonModule
{
    protected override void OnModuleInitialize()
    {
        // Called once when the rig initializes this module
        FirstPersonGrounding grounding = Rig.GetModule<FirstPersonGrounding>();
    }

    protected override void OnModuleUpdate(float dt)
    {
        // Called every frame while enabled
        if (Rig.Input.JumpDown)
        {
            // React to input
        }
    }

    protected override void OnModuleEnabled() { }
    protected override void OnModuleDisabled() { }
}

Add it to the rig at runtime or via the Inspector:

csharp
// Runtime
MyCustomModule module = rig.EnsureModule<MyCustomModule>();

// Later
rig.RemoveModule<MyCustomModule>();

In the Inspector, use the Custom Module Management section at the bottom of the FirstPersonRig inspector to add/remove custom modules via dropdown.

5. Make objects interactable

Implement IPlayerInteractable on any MonoBehaviour:

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Interfaces;

public class PickupItem : MonoBehaviour, IPlayerInteractable
{
    public void OnHoverEnter()
    {
        // Show highlight
    }

    public void OnHoverExit()
    {
        // Hide highlight
    }

    public void OnInteract(FirstPersonRig rig)
    {
        // Pick up the item
        Destroy(gameObject);
    }
}

The FirstPersonInteraction module uses SphereCast with priority scoring -- objects closer to screen center and nearer to the player rank higher.

6. Make objects targetable at range

Implement IPlayerTargetable for long-range targeting (separate from arms-reach interaction):

csharp
using Framework.CarrotControllers.FirstPerson.Interaction;

public class EnemyNPC : MonoBehaviour, IPlayerTargetable
{
    public string GetDisplayName() => "Enemy Soldier";
    public string GetDescription() => "Armed and dangerous";
    public void OnTargetEnter() { /* show UI tooltip */ }
    public void OnTargetExit() { /* hide UI tooltip */ }
}

7. Discoverable objects (gaze-based)

Implement IPlayerDiscoverable for objects the player discovers by looking at them:

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Interfaces;

public class SecretArea : MonoBehaviour, IPlayerDiscoverable
{
    public void OnDiscovered(FirstPersonRig rig)
    {
        Debug.Log("Area discovered!");
    }
}

The FirstPersonTargeting module requires sustained focused gaze (default 1.5 seconds within ~11.5 degrees of center) before triggering discovery.

8. Apply external forces

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using UnityEngine;

FirstPersonRig rig = GetComponent<FirstPersonRig>();

// Knockback from explosion
rig.AddExternalForce(knockbackDirection * knockbackStrength);

// Wind zone (continuous, call each frame)
rig.AddVelocity(windDirection * windStrength);

9. Cinematic camera control

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Interaction;

FirstPersonRig rig = GetComponent<FirstPersonRig>();
FirstPersonCinematicLook cinematic = rig.EnsureModule<FirstPersonCinematicLook>();

// Look at a target with slight zoom
cinematic.LookAt(targetTransform, fovOffset: -10f);

// Or look in a direction
cinematic.LookDirection(Vector3.forward, fovOffset: 0f);

// Return control to the player
cinematic.ReturnControl();

10. Listen for ground events

csharp
using Carrot.Characters.Controllers.FirstPerson.Runtime;
using Carrot.Characters.Controllers.FirstPerson.Runtime.Physical;

FirstPersonRig rig = GetComponent<FirstPersonRig>();
FirstPersonGrounding grounding = rig.GetModule<FirstPersonGrounding>();

grounding.Landed.Add(() => Debug.Log("Landed!"));
grounding.LeftGround.Add(() => Debug.Log("Airborne!"));
// .Add() returns an unsubscribe Action — capture it if you need to detach later.

Add kids.kapish.signals to your asmdef references if using these signals from your own code.

Tips

  • Auto-build simplifies setup. Leave autoBuildHierarchy on and the rig creates the entire camera/module hierarchy on Reset. You only need to wire up Input Actions.
  • Module order matters. Use [ModuleOrder] to control when your custom module runs relative to built-in modules. Negative orders run earlier.
  • ViewModel layer for hands. Create a ViewModel layer and assign hand/weapon models to it. The camera stack automatically splits culling between world and hands cameras.
  • Crouch modes. Toggle holdToCrouch on the FirstPersonCrouch module to switch between hold and toggle crouch behavior.
  • Jump buffering. The FirstPersonJump module has a 100ms buffer window by default, so jumps feel responsive even with slight timing mismatches.

Carrot