Unity Character System - AlchemisTeddy
Unity Character System - AlchemisTeddy

Unity Character System - AlchemisTeddy

Created on:
Team Size: 2
Time Frame: 5 days
Tool Used: Unity/C#

When building a character-driven game, the core systems that govern player interaction are probably the most important part of the experience.
They define the entire feel of the game.

In this technical deep dive, we’ll deconstruct a well-structured, component-based character architecture built in Unity.
We’ll analyze how it leverages a point-and-click NavMeshAgent for movement, a custom camera controller, an event-driven input system, and a robust, interface-based framework for interactions and persistence.

Let’s begin by dissecting the core components that define the player’s immediate experience starting with the
3Cs (Character, Camera, Controls).

Character

The character’s physical presence and actions are orchestrated by the PlayerAnimatorController. This script serves as a sophisticated bridge between gameplay events and visual animations, using a NavMeshAgent for movement and responding to static C# events for interactions.

Movement via NavMeshAgent

Character locomotion is driven by a NavMeshAgent, which offloads the complex logic of pathfinding to Unity’s built-in system.
In Update(), the controller reads the navMeshAgent.velocity.magnitude and passes this value to the Animator, elegantly synchronizing the walk/run/idle animations with the character’s actual speed.

Animation (Event-Driven and Animation-Keyed Logic)

The architecture shines in how it handles complex interactions like picking up items.
The system is decoupled and event-driven, initiated by a static C# event.

The Pickup Sequence

Event Subscription :

The PlayerAnimatorController subscribes to the PlayerInteraction.OnCollect static event. This decouples the animation system from the interaction system.

// In PlayerAnimatorController.cs
// 1. Subscribes to a static event.
private void OnEnable() { PlayerInteraction.OnCollect += StartCollectAnimation; }

Initiating the Animation :

When the OnCollect event fires, the StartCollectAnimation() method is called. It triggers the “PickUp” animation and freezes the character by setting navMeshAgent.speed to zero, preventing unnatural movement.

// 2. Freezes the player and starts the animation.
void StartCollectAnimation(WorldItem item)
{
    currentlyHeldItem = item;
    animator.SetTrigger(PickUpTrigger);
    navMeshAgent.speed = 0;
}

Animation-Keyed Events :

Public methods like PlaceObjectInHand() and EndCollection() are invoked directly by Animation Events placed on the animation’s timeline.
This ensures gameplay logic is perfectly synchronized with the visuals. PlaceObjectInHand() parents the item to the character’s hand at the precise frame of contact, while EndCollection() restores movement speed and finalizes the collection.

// 3. This is called by an Animation Event from the Animator window.
public void PlaceObjectInHand()
{
    currentlyHeldItem.transform.parent = playerRightHand.transform;
    // ...
}

Diagram

Below is a UML Diagram showing the relationship between scripts/components previously exposed.

PlantUML Diagram

Camera

The camera system is engineered to provide a smooth, player-controlled third-person follow-cam experience. It operates independently on its own MonoBehaviour, PlayerCamera.cs, ensuring its logic is decoupled from character movement.

All camera calculations are performed within LateUpdate() to guarantee that any character movement from the Update() cycle has already completed, a critical step to eliminate visual jitter.

The camera’s core behavior involves maintaining a consistent offset from the player while dynamically handling zoom and rotation.

Zoom Functionality

The player can adjust the camera’s distance using the mouse scroll wheel. This is managed in the HandleZoom() method, which modifies a currentZoomDistance variable. This value is then clamped within a designer-specified range (minMaxZoomDistance) to prevent the camera from moving too close or too far from the character.

// In PlayerCamera.cs
private void HandleZoom()
{
    float scrollInput = Input.GetAxis("Mouse ScrollWheel");
    if (scrollInput != 0f)
    {
        currentZoomDistance -= scrollInput * zoomSpeed;
        currentZoomDistance = Mathf.Clamp(currentZoomDistance, minMaxZoomDistance.x, minMaxZoomDistance.y);
    }
}

Rotation Control

The script implements a subtle orbital rotation. By holding the middle mouse button, the player can pivot the camera horizontally. This rotation is constrained by maxRotationAngle to keep the player centered.

A key detail is the smooth return-to-center functionality: when the button is released, the camera doesn’t snap back but instead uses Mathf.Lerp to gracefully interpolate its rotation back to zero, providing a polished and fluid user experience.

// In PlayerCamera.cs
private void HandleRotation()
{
    if (Input.GetMouseButton(2))
    {
        float mouseX = Input.GetAxis("Mouse X") * rotationSpeed;
        currentRotation -= mouseX;
        currentRotation = Mathf.Clamp(currentRotation, -maxRotationAngle, maxRotationAngle);
    }
    else
    {
        // Smoothly interpolate the rotation back to zero
        currentRotation = Mathf.Lerp(currentRotation, 0f, Time.deltaTime * rotationSpeed * 20.0f);
    }
}

Diagram

PlantUML Diagram

Controls

The input architecture is built directly on Unity’s modern Input System package, leveraging its event-driven nature.

A PlayerInput component on the player object sends messages directly to methods within the PlayerControler.cs script, creating a clean, direct link between a physical input and a gameplay action.

The PlayerControler.cs script acts as the central hub for all incoming inputs.
Its primary role is to receive input events and delegate the resulting actions to other, more specialized components, ensuring a strong separation of concerns.

Direct Event Handling & Delegation

Each public method, such as OnLook() or OnInventory(), is directly mapped to an Input Action.
When an action is performed, the corresponding method is immediately invoked and delegates the task to the appropriate manager (e.g., playerActions or playerInventoryManager).

// In PlayerControler.cs
public void OnLook(InputValue value)
{
    aim = value.Get<Vector2>();
    playerActions.AimCheck(aim); // Delegates to PlayerActions
}

public void OnInventory(InputValue value)
{
    playerInventoryManager.ToggleInventoryVisibility(); // Delegates to PlayerInventoryManager
}

Stateful Toggles

For actions like pausing, the script implements a simple boolean toggle.
The OnPause() method flips the pause boolean only when the input is first pressed (value.isPressed), preventing it from toggling every frame. It then uses this state to control Time.timeScale.

Diagram

This is the UML diagram for the Controls of the character

PlantUML Diagram

Interaction

The player’s ability to affect the game world is managed by the PlayerInteraction script, which uses a point-and-click style of control, discerning between movement and targeted interactions.

The HandleLeftClick() method determines player intent by firing a ray and checking what it hits in a specific order of priority:

  • UI First :
    It checks EventSystem.current.IsPointerOverGameObject() to prevent clicks from passing through UI.

  • Interactables Second :
    It casts a Ray looking for objects on the interactableLayer. If one is hit, it begins the FollowAndInteractRoutine.

  • Ground as Fallback :
    If no interactable is hit, it casts against the groundLayer and interprets the click as a movement command for the NavMeshAgent.

Interfaces and Events

The system’s extensibility is achieved through C# interfaces and events. After navigating to a target, the FollowAndInteractRoutine checks what the object can do.

Interface-Based Actions

It uses TryGetComponent() to check for interfaces like IDamageable or IActivatable. This allows any object to become interactive simply by implementing the appropriate interface.

Event-Driven Animation

For collectables, it invokes the static OnCollect event:

OnCollect?.Invoke(collectable as WorldItem);

This is the crucial link that the PlayerAnimatorController listens for, triggering the entire pickup sequence. This is a good example of decoupled architecture:
the interaction system announces “a collection has started,” and the animation system reacts.

Diagram

PlantUML Diagram

Items & Inventory

The PlayerInventoryManager is the central authority for all item data, designed with safety and performance in mind. It has design features such as ones listed below

Encapsulation

The inventory list is private, forcing all modifications to go through public methods like AddItem(), which prevents uncontrolled external changes.

Event-Driven UI

A static OnInventoryChanged event is invoked whenever the inventory is modified. UI scripts subscribe to this event to refresh their display, decoupling the data layer from the presentation layer.

ScriptableObject-Based Items

The inventory holds ItemData (ScriptableObjects), allowing designers to create and balance items as project assets without writing code.

Persistence with the ISaveable Interface

The architecture includes a clean, built-in persistence system via the ISaveable interface, which separates the logic of saving from the data being saved.

The PlayerInteraction (for position) and PlayerInventoryManager (for items) both implement the this interface.
To implement it, the following methods were created:

  • CaptureState(): This method packages the object’s current state (e.g., player position or a list of item IDs) into a serializable Dictionary<string, string>. For the inventory, it cleverly uses string.Join() to convert the list of item IDs into a single comma-separated string.

  • RestoreState(): This method performs the reverse. It takes the dictionary, parses the data (e.g., splitting the string of item IDs), and restores the component’s state. For the inventory, it uses a lookup dictionary to efficiently map the saved IDs back to their corresponding ScriptableObject assets.

By conforming to ISaveable, these components can be managed by a global save/load system without that system needing any specific knowledge about how an inventory or player position works, nor about what system will be used to save them.

Final Diagram

PlantUML Diagram
© 2025 Samuel Styles