Skip to main content

๐Ÿฆถ Lesson 1.4: Animation Events & a Character Controller (Mini-Project)

You can now build state machines and blend trees. This capstone ties Module 1 together: you'll fire Animation Events โ€” methods called at exact keyframes, like a Footstep() on each footfall โ€” decide between root motion and moving in code, and wire a small character controller that drives the Animator with the new Input System. By the end you'll have a character that walks, runs, jumps, and plays footsteps in time with its feet.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Add an Animation Event to a clip and call a method at a chosen keyframe
  • Write event-receiver methods like Footstep() and OnAttackHit()
  • Explain root motion vs. moving the character in code, and pick the right one
  • Read a Vector2 move and a jump with the new Input System and feed the Animator
  • Assemble a working character controller that combines everything from Module 1

Estimated Time: 50 minutes  ยท  Prerequisite: Lessons 1.1โ€“1.3 (the full animation pipeline)

In This Lesson

Animation Events: Calling Code at a Keyframe

Some things need to happen at an exact moment in an animation. A footstep sound should play the frame a foot touches the ground โ€” not on a fixed timer that drifts out of sync. A sword should register a hit only during the swing's contact frames. Spawning a dust puff, releasing an arrow, enabling a hitbox: all of these are tied to a specific point in a clip.

An Animation Event solves this. It's a marker you place on a clip's timeline that, when playback reaches it, calls a method on a script attached to the same GameObject as the Animator. The animation itself decides when your code runs.

๐Ÿ“– Definition

Animation Event: a keyframe-anchored function call embedded in an Animation Clip. When the Animator's playhead crosses the marker, Unity invokes the named method (optionally passing a float, int, string, or Object argument) on a component of the animated GameObject.

Adding an Event on the Timeline

You add events in the Animation window (Window โ–ธ Animation โ–ธ Animation) โ€” the clip-authoring window from Lesson 1.1, not the Animator. Select the animated object, pick the clip, scrub the playhead to the frame you want (say, the exact frame a foot plants), and click the Add Event button (the little marker icon) on the timeline. Then type the method name in the Inspector.

Here's the Animation window timeline for a Walk clip with two footstep events placed where each foot lands:

The Unity Animation window with an event marker A recreation of Unity's Animation window. A toolbar at the top has play controls and an Add Event marker button. A timeline ruler shows frames 0 to 60 with a white playhead near frame 15. Two teal event markers sit on the event track at frames 15 and 45, labelled Footstep(). Below, a property list shows Position and Rotation curves for the Hips and legs. A selected-event panel on the right shows Function set to Footstep and a String parameter of left. Animation Walk โ–พ Add Event โ–พ Hips : Position Position.x Position.y Position.z โ–ธ LeftFoot : Rotation โ–ธ RightFoot : Rotation Events 0 15 30 45 60 15 Animation Event Function Footstep String left
Figure 1: The Animation window with two Footstep() event markers on the event track (faithfully recreated). The selected event calls Footstep("left") when the playhead reaches frame 15.

โš ๏ธ Imported clips need an editable copy

You can't add an event to a read-only imported clip. Duplicate it (Ctrl+D) into your own Animations folder, point the Animator state at the copy, and add events there โ€” or use the model's Import Settings โ–ธ Animation tab, which has its own Events foldout for imported clips.

Writing the Receiver Methods

The method you name in the event must be public (or at least accessible) and live on a component attached to the same GameObject as the Animator. The signature must match the argument you pass โ€” no argument, or exactly one float, int, string, or Object. Here's a script handling both a footstep and an attack-hit event:

using UnityEngine;

// Lives on the same GameObject as the Animator.
public class AnimationEventReceiver : MonoBehaviour
{
    [SerializeField] AudioSource audioSource;
    [SerializeField] AudioClip[] footstepClips;   // a few variations
    [SerializeField] float footstepVolume = 0.6f;

    // Called by an Animation Event on the Walk/Run clips.
    // The string argument ("left"/"right") comes from the event.
    public void Footstep(string foot)
    {
        if (footstepClips.Length == 0) return;
        AudioClip clip = footstepClips[Random.Range(0, footstepClips.Length)];
        audioSource.PlayOneShot(clip, footstepVolume);
    }

    // Called by an event on the contact frame of an Attack clip.
    public void OnAttackHit()
    {
        // e.g. enable a hitbox or do a short overlap check here
        Debug.Log("Attack connects on this frame!");
    }
}

Notice how clean the responsibility split is: the animation owns the timing (when does the foot land?) and the script owns the effect (which sound plays). Re-time the clip and the footsteps follow automatically โ€” you never touch code.

โš ๏ธ Silent failures

If the method name is misspelled, isn't public, or lives on a different GameObject, Unity logs a warning and the event simply does nothing. When an event "won't fire," check the spelling matches exactly and the receiver script sits on the same object as the Animator.

Root Motion vs. Moving in Code

There are two ways a character can actually travel across your scene, and choosing the right one is a real design decision.

  • Root Motion โ€” the animation itself moves the character. The clip's root bone contains real forward displacement, and with Apply Root Motion ticked on the Animator, the GameObject moves exactly as the animator authored it. Feet never slide; motion looks perfect. The cost is control: your code doesn't set the speed, the clip does.
  • In-code movement โ€” you move the character yourself (via a CharacterController or Rigidbody) and use the animation only for the look of moving. Full control over speed and direction, but you must match the animation's playback to your movement speed or you get foot sliding (feet skating over the ground).

For this mini-project we'll use in-code movement with a CharacterController: it's the most common approach for responsive gameplay, and it keeps the driving code explicit so you can see exactly how input becomes both movement and animation.

๐Ÿ’ก Rule of thumb. Use root motion for cinematic, mocap-heavy, or precisely-authored movement (a boss's lunge, a climb). Use in-code movement for responsive, player-driven characters where snappy control beats perfect footfalls. Many shipping games blend both.

Mini-Project: The Character Controller

Time to assemble everything from Module 1. We'll build a script that:

  1. Reads a Move (Vector2) and a Jump action from the new Input System.
  2. Moves the character with a CharacterController and applies gravity.
  3. Feeds the Animator's Speed float (driving the Lesson 1.3 blend tree), the Grounded bool, and the Jump trigger (the Lesson 1.2 parameters).
  4. Plays a footstep sound through the Animation Event from earlier in this lesson.

Set-up before the script: on your character, add a Character Controller component and an Audio Source, confirm the Animator has the Module 1 controller assigned, and create an Input Actions asset with a Move (Value / Vector2) and a Jump (Button) action. Then attach both scripts below (the receiver from earlier plus this driver).

using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(CharacterController), typeof(Animator))]
public class PlayerCharacterController : MonoBehaviour
{
    [Header("Input (new Input System)")]
    [SerializeField] InputActionReference moveAction; // Vector2
    [SerializeField] InputActionReference jumpAction; // Button

    [Header("Movement")]
    [SerializeField] float moveSpeed = 6f;    // matches the Run threshold
    [SerializeField] float jumpHeight = 1.2f;
    [SerializeField] float gravity = -18f;
    [SerializeField] float turnSpeed = 12f;

    CharacterController controller;
    Animator animator;
    Vector3 velocity;    // vertical only; horizontal is from input

    // Cache parameter hashes (the Lesson 1.2 tip).
    static readonly int SpeedHash    = Animator.StringToHash("Speed");
    static readonly int GroundedHash = Animator.StringToHash("Grounded");
    static readonly int JumpHash     = Animator.StringToHash("Jump");

    void Awake()
    {
        controller = GetComponent<CharacterController>();
        animator   = GetComponent<Animator>();
    }

    void OnEnable()
    {
        moveAction.action.Enable();
        jumpAction.action.Enable();
        jumpAction.action.performed += OnJump;   // event callback, not polling
    }

    void OnDisable()
    {
        jumpAction.action.performed -= OnJump;
        moveAction.action.Disable();
        jumpAction.action.Disable();
    }

    void Update()
    {
        bool grounded = controller.isGrounded;
        animator.SetBool(GroundedHash, grounded);
        if (grounded && velocity.y < 0f) velocity.y = -2f; // stick to ground

        // 1) Read input and build a world-space move direction.
        Vector2 input = moveAction.action.ReadValue<Vector2>();
        Vector3 move = new Vector3(input.x, 0f, input.y);

        // 2) Rotate toward the move direction so the character faces travel.
        if (move.sqrMagnitude > 0.001f)
        {
            Quaternion target = Quaternion.LookRotation(move);
            transform.rotation = Quaternion.Slerp(
                transform.rotation, target, turnSpeed * Time.deltaTime);
        }

        // 3) Move horizontally, then apply gravity.
        controller.Move(move * moveSpeed * Time.deltaTime);
        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);

        // 4) Feed the Animator: one float drives the whole blend tree.
        float speed = input.magnitude * moveSpeed;
        animator.SetFloat(SpeedHash, speed, 0.1f, Time.deltaTime);
    }

    // Fired once per press by the Input System (Jump is a Button action).
    void OnJump(InputAction.CallbackContext ctx)
    {
        if (!controller.isGrounded) return;
        velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
        animator.SetTrigger(JumpHash);   // the Lesson 1.2 Any State -> Jump
    }
}

Every technique from the module shows up here: the Speed float feeds the blend tree (1.3), the Jump trigger fires the Any State โ†’ Jump transition (1.2), the hashes come from the fragile-strings tip (1.2), and the footstep audio arrives through an Animation Event (this lesson). Because the driver only sets parameters, the animation system and gameplay stay cleanly separated.

โœ… Pro Tip

Use the Input System's performed callback for one-shot actions like Jump (as above) rather than polling ReadValue every frame โ€” it fires exactly once per press, which pairs perfectly with a SetTrigger. Reserve per-frame ReadValue for continuous input like the move stick.

How It All Connects

Here's the data flow of the finished mini-project โ€” from the player's input all the way to sound and motion on screen:

flowchart TD IN["New Input System
Move (Vector2) ยท Jump (Button)"] --> DRV["PlayerCharacterController"] DRV -->|"controller.Move()"| CC["CharacterController
(moves the body)"] DRV -->|"SetFloat Speed"| ANIM["Animator"] DRV -->|"SetTrigger Jump"| ANIM DRV -->|"SetBool Grounded"| ANIM ANIM --> BT["Idle/Walk/Run
blend tree + Jump state"] BT -->|"Animation Event"| RX["AnimationEventReceiver.Footstep()"] RX --> SFX["AudioSource
footstep sound"] BT --> POSE["Posed, moving character"] CC --> POSE

Figure 2: Input drives the controller, which both moves the body and sets Animator parameters; the animation then fires the footstep event.

Hands-on Challenge

๐Ÿ‹๏ธ Mini-Project: Walk, run, jump & footsteps

Objective: Build a playable character that combines every Module 1 skill.

  1. On Timmy, ensure the Animator runs your Module 1 controller (with the Speed blend tree, Grounded bool, and Jump trigger). Add a Character Controller and an Audio Source.
  2. Create an Input Actions asset with a Move (Vector2) and Jump (Button) action. Reference them from the driver's fields.
  3. Attach PlayerCharacterController and AnimationEventReceiver. Assign a few footstep AudioClips.
  4. On an editable copy of the Walk and Run clips, add Footstep Animation Events on the frames each foot lands (pass "left"/"right" if you like).
  5. Press Play. Push the stick to accelerate through the blend, hear a footstep on each footfall, and tap Jump to fire the jump once.
๐Ÿ’ก Hint: it moves but no footsteps play

Confirm the AnimationEventReceiver is on the same GameObject as the Animator, the method is public and spelled exactly Footstep, and the events are on the editable copies of the clips your controller actually uses (not the read-only imports). Also check the Audio Source isn't muted and has clips assigned.

๐Ÿ’ก Hint: the character floats or never lands

Gravity is applied through velocity.y and controller.Move. Make sure the Character Controller's capsule actually rests on the floor (its skin width and center are sane) so isGrounded returns true; otherwise Grounded stays false and the ground snap never kicks in.

โœ… Success check

Timmy accelerates smoothly from idle to run as you push the stick, faces his travel direction, plays a footstep synced to each footfall, and jumps exactly once per button press โ€” all driven by parameters, with movement and animation cleanly separated.

๐ŸŽฏ Quick Quiz

Question 1: Where must the method called by an Animation Event live?

Question 2: You want responsive, code-controlled movement speed with no feet sliding. Which fits best?

Question 3: In the driver, why is Jump handled with the Input System's performed callback instead of reading it every frame?

Summary

๐ŸŽ‰ Key Takeaways

  • Animation Events call a method at an exact keyframe โ€” ideal for footsteps, hit checks, and effect spawns.
  • The receiver method must be accessible and sit on the same GameObject as the Animator; a mismatch fails silently.
  • Root motion lets the clip move the character (perfect footfalls, less control); in-code movement gives control (watch for foot sliding).
  • A clean driver only sets parameters โ€” SetFloat Speed, SetBool Grounded, SetTrigger Jump โ€” keeping gameplay and animation decoupled.
  • The new Input System pairs a polled Move vector with a one-shot Jump performed callback.
  • You combined clips, a controller, a blend tree, and events into one working character โ€” that's the whole Module 1 pipeline.

๐Ÿš€ What's Next?

Your character works, but notice how much the driver script knows about โ€” input, movement, animation, audio all tangled in one class. As projects grow, that coupling becomes the enemy. In Module 2: Architecture & ScriptableObjects, starting with Lesson 2.1: Thinking in Systems: Decoupling & Interfaces, you'll learn to pull these responsibilities apart so your systems stay flexible and testable.

๐Ÿฆถ Module 1 complete

Clips, states, transitions, blend trees, and events โ€” you can now animate a character from import to playable. Next we make the code behind it just as clean.