Appearance
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:
- Ensures a
CharacterControllerwith sane defaults (radius 0.3, 45-degree slope, Player layer). - Auto-builds the camera hierarchy:
CameraPivot > CameraPitchOffset > CameraBob. - Discovers and caches all
FirstPersonModulecomponents in its children. - Initializes modules in slot order, then builds per-phase sorted lists.
Each frame, the rig:
- Resets
frameVelocityto zero. - Calls
OnModuleUpdate(dt)on all update-phase modules in order. Modules contribute velocity viaRig.AddVelocity(). - Applies accumulated
frameVelocity * dtviaCharacterController.Move(). - Calls
OnModuleFixedUpdate(dt)andOnModuleLateUpdate(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):
| Order | Module | Role |
|---|---|---|
| -100 | FirstPersonCameraStack | Camera setup |
| -90 | FirstPersonGrounding | Ground detection |
| -90 | FirstPersonReticle | Reticle origin |
| -60 | FirstPersonCapsule | Capsule dimensions |
| -55 | FirstPersonCrouch | Crouch input |
| -50 | FirstPersonSprint | Sprint input |
| -20 | FirstPersonJump | Jump input |
| -20 | FirstPersonInteraction | Interaction detection |
| -19 | FirstPersonTargeting | Long-range targeting |
| -15 | FirstPersonGravity | Gravity + vertical velocity |
| -10 | FirstPersonLook | Mouse look |
| -5 | FirstPersonMotor | Movement calculation |
| 50 | FirstPersonAudioSource | Audio |
| 100 | FirstPersonCinematicLook | Cinematic 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/LeftGroundsignals (both parameterlessSignal).
Interaction System
Two layers of interaction:
Arms-reach (
FirstPersonInteraction) --SphereCastforward from the reticle origin. Maintains a priority-sorted list ofIPlayerInteractablecandidates scored by distance and center-screen alignment. Handles hover state and interact dispatch.Long-range targeting (
FirstPersonTargeting) --Raycastforward. TracksIPlayerTargetableenter/exit. Optionally tracksIPlayerDiscoverableobjects 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
FirstPersonRigEditorextendsCarrotBehaviourInspectorwith a custom module management panel. Core modules are managed by grouped inspector fields; custom modules get an add/remove dropdown.FirstPersonDebugDrawGizmosdraws 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.
FirstPersonRigcreates 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, andIPlayerDiscoverableare 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
- Add
kids.kapish.characters.controllersand its dependencies (kids.kapish,kids.kapish.input) to your Unity project. - Create a
Playerlayer in Project Settings > Tags and Layers (the rig auto-assigns itself to this layer). - 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: FirstPersonGravity2. 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.signalsto your asmdef references if using these signals from your own code.
Tips
- Auto-build simplifies setup. Leave
autoBuildHierarchyon 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
ViewModellayer and assign hand/weapon models to it. The camera stack automatically splits culling between world and hands cameras. - Crouch modes. Toggle
holdToCrouchon theFirstPersonCrouchmodule to switch between hold and toggle crouch behavior. - Jump buffering. The
FirstPersonJumpmodule has a 100ms buffer window by default, so jumps feel responsive even with slight timing mismatches.