For this class, we looked at a few more advanced techniques to enhance our platform games. The first is Animation Effects, which allow us to call a scripted event from inside an animation clip, which we used to make a “coin box”. Next we looked at using the Physics to test a hitbox for our enemy object!
Part 1: Animation Events
Today we created a classic element from the old side-scrollers, the coin box. Super Mario Brothers featured these boxes throughout their levels, and they were a wonderful devices. A player would jump from underneath, the box would shift up slightly to indicate that your actions caused a reaction, and a spinning coin would pop out, increasing your coin count and playing a happy coin sound to reinforce that a good thing happened, and your brain would release just a little more dopamine. Ahhh….

To construct our coin box, I first created a prefab element that contains a box sprite from our environment sprite sheet, and that sprite has a box collider to receive the impact event, and the animator component so that we can make it move. (Remember, if you make the moving object a child of the prefab parent object, then the transform animation will be in relationship to its steady parent object. This way the box will work the same wherever the prefab is placed and we do not need to use Apply Root Motion to keep it in place)
I generated two animation clips, a quick upward bump animation and a single frame idle animation that will be our default state. I generated a Trigger parameter that we can fire off when the player touches our Box Collider 2D. This will move us to the Bounce animation. Rather than set an exit condition to this clip, we select “Has Exit Time”. This way, once the clip has run, the exit transition will automatically be processed and we will return to the idle state.
We wrote a quick script (BoxScript.cs) to attach to the box object that uses OnCollisionEnter2D( ) to detect a collision with the player, and the Animator component to set the trigger. First step completed!
Next, we created a jumping coin. We built this with our spinning coin sequence, running at a higher sample rate of 24fps, and animated the position so it made a quick jump up and back down. We made this a prefab object as well, so that we could call it into existence whenever a box is bounced.
Now it is time to connect the two objects. In our box script, we added a public function LaunchCoin( ) that would instantiate our coin prefab at the box position when called (and then destroy it momentarily afterwards because it’s always good to clean up after ourselves). Because this CoinBoxScript class is attached to the same object as our Animation, and because the function was made public, it is now available to us as an animation event.

At the height of our boxes bounce clip, I added an Animation Event to the timeline. You can see it up there just under the timestamp – it’s the little white marker. To create an event, you hit the “Add Event” button which is located just next to the “Add Keyframe” marker in the Animation window. (Just under the Sample Rate field).


The Event itself can be configured in the Inspector. Select the event marker in the timeline and then use the dropdown in the Inspector to associate it with a public available function attached to that object.
Here our animation event takes no arguments, but you can configure one parameter to be passed when the event is called. This can be either a float, int, string, or object reference. Now we have a coin that pops out of our box. Our coin sticks around a little longer than we would like, so we disable looping on the Animation Clip. We add a nice coin sound to the Prefab, and make sure that Play On Awake is set to true, and now our coin makes noise. Finally, we send a note to the Game Manager to increase our score by 10.
Directional Hits
One issue that we now have is that our coin box is triggered regardless of which direction our character touches this, but we only want to trigger this action when we hit from below the box. To determine how the Player is hitting our collider, we can look at the contacts – the points of contact between our two colliders. This data is part of the Collision object, and using this we can access the normal – a vector that is perpendicular to the edge of the other collider at the point of contact. This provides useful data because it gives us a sense of direction for the other collider, and we can test this direction to determine which part of the player has hit us. Since we are using a Capsule Collider – our straight sides will have normals of (1, 0) or (-1, 0), while the curved top and bottom will transition to (0, 1) on the top and (0, -1) at the bottom. By testing for normal with a y-value greater than 0.8f, we can be certain that the contact point must near the top of the player character’s collider.
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.CompareTag("Player"))
{
// check to make sure this is the players head by checking the contact normal
ContactPoint2D thisContact = collision.GetContact(0);
if (thisContact.normal.y > 0.8f)
{
// animate our box
animator.SetTrigger("BoxJump");
}
}
}
Part 2: Physics Overlays
Next we created a simple “attack” animation for our player where he swings a sword. This melee weapon should only have a limited range, meaning that there is a limited area that our enemy must be in for the hit to count as effective. To make this determination, we create a “hitbox” (an industry term for the collider or collision check that determines if / where a hit occurs). To keep our mechanics feeling responsive and “juicy”, we may want to make a few design decisions
- The animation show show the sword swinging
- The “hit” should be calculated in the area we see our sword swinging
- The “hit” should occur at the apex moment of the swing, when the sword is fully extended
- The “hit” needs to check the area in front of the character’s facing direction
- We only want our hit to check for enemy objects
Based upon this list, we can make a few determinations. First, we will want to check a volume that is offset from our player, in the facing direction. We will use our Physics2D methods, in this case BoxOverlap to perform a collider check similar to how we previously detected if our player was touching the ground. We can use a LayerMask to limit our collider check to objects with the Enemy layer. Next, we want to call this check at a specific moment in the swing – and of course we can use Animation Event to make this call.
The “front facing” requirement presents a slightly trickier issue, as our character current can move in either direction, and accomplishes this by inverting its scale in the X direction. This means we cannot simply check a position in front of our current position, because the BoxOverlap requires a Vector2 world position, and a Vector2 size. We would need to know which direction to place this, and offset that check in the proper direction. Rather than worrying about calculating this, we can simply create a child object (in this case a box sprite) and it will automatically flip with the parent object, but let us read its world position. We fetch the transform of this object for our script, and use the Transform.position (instead of localPosition) for the world placement, and Transform.scale values to allow us to define the size. Finally, we place that on our Behavior layer which we have masked from camera view.
Sending a Message
Our player animations are all running on a child object that holds our Sprite. This has worked great for us so far, but now we encounter an issue – we can ONLY use Animation Events to call a public function on the object that animation is attached to, and we want to call our Melee Attack from our parent object. It is clear that we will have to create a second script that communicates with the parent object.
One way that we could structure this would be to have our new player animation script find the character controller script on the parent object, requiring a very specific and direct connection. But there is an alternative, one that allows more flexibility – we can send a message.
The SendMessage( ) command allows us to send a method call to any component attached to a game object. We simply pass in the string and optional parameters, and it will attempt to call this on each component. This is similar to how our Start( ) and Update( ) commands work – the game engine calls that message, and if we have a method with that name, it executes. In the event that you do not know the exact game object that you need, or if you wish to send that message to all children as well, you can use BroadcastMessage( ) which is what we established in class.
BoxCoinScript.cs
using UnityEngine;
public class BoxCoinScript : MonoBehaviour
{
private Animator animator;
public GameObject coinPrefab;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
animator = GetComponent<Animator>();
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.CompareTag("Player"))
{
// check to make sure this is the players head by checking the contact normal
ContactPoint2D thisContact = collision.GetContact(0);
Debug.Log("Contact Point: " + thisContact.normal);
if (thisContact.normal.y > 0.8f)
{
// animate our box
animator.SetTrigger("BoxJump");
}
}
}
public void LaunchCoin()
{
GameObject launchedCoin = Instantiate(coinPrefab, transform);
Destroy(launchedCoin, 0.5f);
GameManager.S.IncreaseScore(10);
}
}
PlayerAnimScript.cs
using UnityEngine;
public class PlayerAnimScript : MonoBehaviour
{
public void CallMeleeAttack()
{
transform.parent.BroadcastMessage("MeleeAttack");
}
}
CharacterController2D.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
// Character Controller 2D is based upon the 2D character controller for Unity by Sharp Blog Code
// URL: https://www.sharpcoderblog.com/blog/2d-platformer-character-controller
//
// Adapted by: Tom Corbett on 6/3/2025
[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(CapsuleCollider2D))]
public class CharacterController2D : MonoBehaviour
{
// Move player in 2D space
public float maxSpeed = 3.4f;
public float jumpHeight = 6.5f;
public float gravityScale = 1.5f;
float moveDirection = 0;
bool isGrounded = false;
bool isAlive = true;
InputAction moveAction;
InputAction jumpAction;
InputAction attackAction;
Rigidbody2D r2d;
// public Transform groundCheckPosition;
public Vector3 groundCheckOffset;
public float groundCheckRadius;
public LayerMask groundLayerMask;
// melee attack variables
public Transform meleeHitBoxObject;
public LayerMask meleeHitMask;
// animation parameters
public Animator animator;
// Use this for initialization
void Start()
{
r2d = GetComponent<Rigidbody2D>();
r2d.freezeRotation = true;
r2d.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
r2d.gravityScale = gravityScale;
moveAction = InputSystem.actions.FindAction("Move");
jumpAction = InputSystem.actions.FindAction("Jump");
attackAction = InputSystem.actions.FindAction("Attack");
}
// Update is called once per frame
void Update()
{
/*
// set the move direction
moveDirection = moveAction.ReadValue<Vector2>().x;
*/
if (isAlive)
{
moveDirection = Input.GetAxis("Horizontal");
animator.SetFloat("Speed", Mathf.Abs(moveDirection));
animator.SetBool("Grounded", isGrounded);
// are we jumping?
if (jumpAction.WasPressedThisFrame() && isGrounded)
{
r2d.linearVelocity = new Vector2(r2d.linearVelocity.x, jumpHeight);
}
// are we facing the right direction
Vector3 currentScale = transform.localScale;
if (moveDirection > 0f && currentScale.x < 0f)
{
transform.localScale = new Vector3(Mathf.Abs(currentScale.x), currentScale.y, currentScale.z);
}
else if (moveDirection < 0f && currentScale.x > 0f)
{
transform.localScale = new Vector3(-Mathf.Abs(currentScale.x), currentScale.y, currentScale.z);
}
if (attackAction.WasPressedThisFrame())
{
animator.SetTrigger("PlayerAttack");
}
}
}
void FixedUpdate()
{
if (isAlive)
{
// reset the grounded state
isGrounded = false;
// look for colliders that are not us
Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position + groundCheckOffset, groundCheckRadius, groundLayerMask);
// was there a collider
if (colliders.Length > 0) { isGrounded = true; }
// Apply movement velocity
r2d.linearVelocity = new Vector2((moveDirection) * maxSpeed, r2d.linearVelocity.y);
}
}
public void MeleeAttack()
{
if (isAlive)
{
// check for any collider
Collider2D thisCollider = Physics2D.OverlapBox(meleeHitBoxObject.position, meleeHitBoxObject.localScale, 0f, meleeHitMask);
if (thisCollider) {
Debug.Log("Just hit object: " + thisCollider.gameObject.name);
} else
{
Debug.Log("whiff");
}
}
}
private void PlayerDied()
{
// queue our death
animator.SetTrigger("PlayerDeath");
// turn off my rigidbody
r2d.bodyType = RigidbodyType2D.Kinematic;
r2d.linearVelocity = Vector2.zero;
// destroy our instance with a timer
Destroy(this.gameObject, 2f);
// no longer alive
isAlive = false;
// restart the level (do this after a coroutine)
LevelManager.level.LevelEvent_RESTART_LEVEL();
}
private void OnCollisionEnter2D(Collision2D collision)
{
if (collision.gameObject.tag == "Enemy")
{
// get the enemy script
EnemyScript thisEnemy = collision.gameObject.GetComponent<EnemyScript>();
if (thisEnemy)
{
if (thisEnemy.amIAlive)
{
// queue the death
PlayerDied();
}
}
}
}
private void OnTriggerEnter2D(Collider2D collision)
{
if (collision.gameObject.tag == "END_OF_LEVEL")
{
LevelManager.level.LevelEvent_END_OF_LEVEL();
}
if (collision.gameObject.tag == "END_OF_GAME")
{
LevelManager.level.LevelEvent_GoToMenuScreen();
}
}
private void OnDrawGizmos()
{
// show the grounded radius
if (isGrounded)
{
Gizmos.color = Color.yellow;
} else
{
Gizmos.color = Color.red;
}
// draw the circle for the ground check
Gizmos.DrawWireSphere(transform.position + groundCheckOffset, groundCheckRadius);
Gizmos.color = Color.yellow;
Gizmos.DrawCube(meleeHitBoxObject.position, meleeHitBoxObject.localScale);
}
}