CharacterController for 2D games?

Hello I am quite new to Unity.
I wanted to ask if I can use the CharacterController class for 2D movement aswell? I do not have a sidescroller game, but rather a roguelike/dungeon crawler. I have problems implementing a movement function with wall collision detection. I tried the Charactercontroller, but it didnt work. I tried the CharacterController2D from prime31, but that’s for sidescrolling games.
I watched the Roguelike tutorial from Unity, but that movement function really is too complicated and seems not to work either.

So my main question is: Is it true, that the CharacterController is only usable for 3D games? Did not find a clear statement about that.

And the second: Do you know a good movement tutorial with collision detection?

Thanks,
Sym

The CharacterController is a basic collision detection option that’s usually meant for physics based games (games with gravity and jumping and such). It would work fine in 2D, but you’d have to change a few things in the way that you’re using it.

If you’re making a turn-based roguelike dungeon crawler, where there are tiles and you can only move one tile at a time, then the character controller is far more than you would need; You could easily get by with raycasting at the time of movement.

Real-time dungeon crawlers would make a better use of the character controller, but at that point you’re not really making a “2d game”, you’re just making a 3D game from a top-down perspective. All you would have to do to make a 3D dungeon crawler would be to move the camera up above everything and point it straight down. If you set the camera to orthographic instead of perspective, then nobody would be able to tell that the game is 3D.

Now that you think of it like that, can you see how the character controller would be used in a 2D game? Make it so the “gravity” of the scene is facing towards the floor of your map, instead of downward (towards the bottom of the screen). Normally a character controller would take Vertical movement as moving along the Z axis, so change it to move along the Y axis (up and down). Left and right movement is the exact same in any case. And voila! regular collision detection using a character controller in 2d!

Here’s a CharacterController2D script I found on the web:

#define DEBUG_CC2D_RAYS
using UnityEngine;
using System;
using System.Collections.Generic;


[RequireComponent( typeof( BoxCollider2D ), typeof( Rigidbody2D ) )]
public class CharacterController2D : MonoBehaviour
{
	#region internal types
	
	private struct CharacterRaycastOrigins
	{
		public Vector3 topLeft;
		public Vector3 bottomRight;
		public Vector3 bottomLeft;
	}
	
	public class CharacterCollisionState2D
	{
		public bool right;
		public bool left;
		public bool above;
		public bool below;
		public bool becameGroundedThisFrame;
		public bool wasGroundedLastFrame;
		public bool movingDownSlope;
		public float slopeAngle;
		
		
		public bool hasCollision()
		{
			return below || right || left || above;
		}
		
		
		public void reset()
		{
			right = left = above = below = becameGroundedThisFrame = movingDownSlope = false;
			slopeAngle = 0f;
		}
		
		
		public override string ToString()
		{
			return string.Format( "[CharacterCollisionState2D] r: {0}, l: {1}, a: {2}, b: {3}, movingDownSlope: {4}, angle: {5}, wasGroundedLastFrame: {6}, becameGroundedThisFrame: {7}",
			                     right, left, above, below, movingDownSlope, slopeAngle, wasGroundedLastFrame, becameGroundedThisFrame );
		}
	}
	
	#endregion
	
	
	#region events, properties and fields
	
	public event Action<RaycastHit2D> onControllerCollidedEvent;
	public event Action<Collider2D> onTriggerEnterEvent;
	public event Action<Collider2D> onTriggerStayEvent;
	public event Action<Collider2D> onTriggerExitEvent;
	
	
	/// <summary>
	/// toggles if the RigidBody2D methods should be used for movement or if Transform.Translate will be used. All the usual Unity rules for physics based movement apply when true
	/// such as getting your input in Update and only calling move in FixedUpdate amonst others.
	/// </summary>
	public bool usePhysicsForMovement = false;
	
	[SerializeField]
	[Range( 0.001f, 0.3f )]
	private float _skinWidth = 0.02f;
	
	/// <summary>
	/// defines how far in from the edges of the collider rays are cast from. If cast with a 0 extent it will often result in ray hits that are
	/// not desired (for example a foot collider casting horizontally from directly on the surface can result in a hit)
	/// </summary>
	public float skinWidth
	{
		get { return _skinWidth; }
		set
		{
			_skinWidth = value;
			recalculateDistanceBetweenRays();
		}
	}
	
	
	/// <summary>
	/// mask with all layers that the player should interact with
	/// </summary>
	public LayerMask platformMask = 0;
	
	/// <summary>
	/// mask with all layers that trigger events should fire when intersected
	/// </summary>
	public LayerMask triggerMask = 0;
	
	/// <summary>
	/// mask with all layers that should act as one-way platforms. Note that one-way platforms should always be EdgeCollider2Ds. This is private because it does not support being
	/// updated anytime outside of the inspector for now.
	/// </summary>
	[SerializeField]
	private LayerMask oneWayPlatformMask = 0;
	
	/// <summary>
	/// the max slope angle that the CC2D can climb
	/// </summary>
	/// <value>The slope limit.</value>
	[Range( 0, 90f )]
	public float slopeLimit = 30f;
	
	/// <summary>
	/// the threshold in the change in vertical movement between frames that constitutes jumping
	/// </summary>
	/// <value>The jumping threshold.</value>
	public float jumpingThreshold = 0.07f;
	
	
	/// <summary>
	/// curve for multiplying speed based on slope (negative = down slope and positive = up slope)
	/// </summary>
	public AnimationCurve slopeSpeedMultiplier = new AnimationCurve( new Keyframe( -90, 1.5f ), new Keyframe( 0, 1 ), new Keyframe( 90, 0 ) );
	
	[Range( 2, 20 )]
	public int totalHorizontalRays = 8;
	[Range( 2, 20 )]
	public int totalVerticalRays = 4;
	
	
	/// <summary>
	/// this is used to calculate the downward ray that is cast to check for slopes. We use the somewhat arbitrary value 75 degrees
	/// to calculate the length of the ray that checks for slopes.
	/// </summary>
	private float _slopeLimitTangent = Mathf.Tan( 75f * Mathf.Deg2Rad );
	
	[Range( 0.8f, 0.999f )]
	public float triggerHelperBoxColliderScale = 0.95f;
	
	
	[HideInInspector][NonSerialized]
	public new Transform transform;
	[HideInInspector][NonSerialized]
	public BoxCollider2D boxCollider;
	[HideInInspector][NonSerialized]
	public Rigidbody2D rigidBody2D;
	
	[HideInInspector][NonSerialized]
	public CharacterCollisionState2D collisionState = new CharacterCollisionState2D();
	[HideInInspector][NonSerialized]
	public Vector3 velocity;
	public bool isGrounded { get { return collisionState.below; } }
	
	private const float kSkinWidthFloatFudgeFactor = 0.001f;
	
	#endregion
	
	
	/// <summary>
	/// holder for our raycast origin corners (TR, TL, BR, BL)
	/// </summary>
	private CharacterRaycastOrigins _raycastOrigins;
	
	/// <summary>
	/// stores our raycast hit during movement
	/// </summary>
	private RaycastHit2D _raycastHit;
	
	/// <summary>
	/// stores any raycast hits that occur this frame. we have to store them in case we get a hit moving
	/// horizontally and vertically so that we can send the events after all collision state is set
	/// </summary>
	private List<RaycastHit2D> _raycastHitsThisFrame = new List<RaycastHit2D>( 2 );
	
	// horizontal/vertical movement data
	private float _verticalDistanceBetweenRays;
	private float _horizontalDistanceBetweenRays;
	// we use this flag to mark the case where we are travelling up a slope and we modified our delta.y to allow the climb to occur.
	// the reason is so that if we reach the end of the slope we can make an adjustment to stay grounded
	private bool _isGoingUpSlope = false;
	
	
	#region Monobehaviour
	
	void Awake()
	{
		// add our one-way platforms to our normal platform mask so that we can land on them from above
		platformMask |= oneWayPlatformMask;
		
		// cache some components
		transform = GetComponent<Transform>();
		boxCollider = GetComponent<BoxCollider2D>();
		rigidBody2D = GetComponent<Rigidbody2D>();
		
		// here, we trigger our properties that have setters with bodies
		skinWidth = _skinWidth;
		
		// we want to set our CC2D to ignore all collision layers except what is in our triggerMask
		for( var i = 0; i < 32; i++ )
		{
			// see if our triggerMask contains this layer and if not ignore it
			if( ( triggerMask.value & 1 << i ) == 0 )
				Physics2D.IgnoreLayerCollision( gameObject.layer, i );
		}
	}
	
	
	public void OnTriggerEnter2D( Collider2D col )
	{
		if( onTriggerEnterEvent != null )
			onTriggerEnterEvent( col );
	}
	
	
	public void OnTriggerStay2D( Collider2D col )
	{
		if( onTriggerStayEvent != null )
			onTriggerStayEvent( col );
	}
	
	
	public void OnTriggerExit2D( Collider2D col )
	{
		if( onTriggerExitEvent != null )
			onTriggerExitEvent( col );
	}
	
	#endregion
	
	
	[System.Diagnostics.Conditional( "DEBUG_CC2D_RAYS" )]
	private void DrawRay( Vector3 start, Vector3 dir, Color color )
	{
		Debug.DrawRay( start, dir, color );
	}
	
	
	#region Public
	
	/// <summary>
	/// attempts to move the character to position + deltaMovement. Any colliders in the way will cause the movement to
	/// stop when run into.
	/// </summary>
	/// <param name="deltaMovement">Delta movement.</param>
	public void move( Vector3 deltaMovement )
	{
		// save off our current grounded state which we will use for wasGroundedLastFrame and becameGroundedThisFrame
		collisionState.wasGroundedLastFrame = collisionState.below;
		
		// clear our state
		collisionState.reset();
		_raycastHitsThisFrame.Clear();
		_isGoingUpSlope = false;
		
		var desiredPosition = transform.position + deltaMovement;
		primeRaycastOrigins( desiredPosition, deltaMovement );
		
		
		// first, we check for a slope below us before moving
		// only check slopes if we are going down and grounded
		if( deltaMovement.y < 0 && collisionState.wasGroundedLastFrame )
			handleVerticalSlope( ref deltaMovement );
		
		// now we check movement in the horizontal dir
		if( deltaMovement.x != 0 )
			moveHorizontally( ref deltaMovement );
		
		// next, check movement in the vertical dir
		if( deltaMovement.y != 0 )
			moveVertically( ref deltaMovement );
		
		
		// move then update our state
		if( usePhysicsForMovement )
		{
			rigidBody2D.MovePosition( transform.position + deltaMovement );
			velocity = rigidBody2D.velocity;
		}
		else
		{
			transform.Translate( deltaMovement, Space.World );
			
			// only calculate velocity if we have a non-zero deltaTime
			if( Time.deltaTime > 0 )
				velocity = deltaMovement / Time.deltaTime;
		}
		
		// set our becameGrounded state based on the previous and current collision state
		if( !collisionState.wasGroundedLastFrame && collisionState.below )
			collisionState.becameGroundedThisFrame = true;
		
		// if we are going up a slope we artificially set a y velocity so we need to zero it out here
		if( _isGoingUpSlope )
			velocity.y = 0;
		
		// send off the collision events if we have a listener
		if( onControllerCollidedEvent != null )
		{
			for( var i = 0; i < _raycastHitsThisFrame.Count; i++ )
				onControllerCollidedEvent( _raycastHitsThisFrame *);*
  •  }*
    
  • }*

  • ///

    *

  • /// moves directly down until grounded*

  • /// *

  • public void warpToGrounded()*

  • {*

  •  do*
    
  •  {*
    
  •  	move( new Vector3( 0, -1f, 0 ) );*
    
  •  } while( !isGrounded );*
    
  • }*

  • ///

    *

  • /// this should be called anytime you have to modify the BoxCollider2D at runtime. It will recalculate the distance between the rays used for collision detection.*

  • /// It is also used in the skinWidth setter in case it is changed at runtime.*

  • /// *

  • public void recalculateDistanceBetweenRays()*

  • {*

  •  // figure out the distance between our rays in both directions*
    
  •  // horizontal*
    

var colliderUseableHeight = boxCollider.size.y * Mathf.Abs( transform.localScale.y ) - ( 2f * _skinWidth );

  •  _verticalDistanceBetweenRays = colliderUseableHeight / ( totalHorizontalRays - 1 );*
    
  •  // vertical*
    

var colliderUseableWidth = boxCollider.size.x * Mathf.Abs( transform.localScale.x ) - ( 2f * _skinWidth );

  •  _horizontalDistanceBetweenRays = colliderUseableWidth / ( totalVerticalRays - 1 );*
    
  • }*

  • #endregion*

  • #region Private Movement Methods*

  • ///

    *

  • /// resets the raycastOrigins to the current extents of the box collider inset by the skinWidth. It is inset*

  • /// to avoid casting a ray from a position directly touching another collider which results in wonky normal data.*

  • /// *

  • /// Future position.*

  • /// Delta movement.*

  • private void primeRaycastOrigins( Vector3 futurePosition, Vector3 deltaMovement )*

  • {*

  •  // our raycasts need to be fired from the bounds inset by the skinWidth*
    
  •  var modifiedBounds = boxCollider.bounds;*
    

modifiedBounds.Expand( -2f * _skinWidth );

  •  _raycastOrigins.topLeft = new Vector2( modifiedBounds.min.x, modifiedBounds.max.y );*
    
  •  _raycastOrigins.bottomRight = new Vector2( modifiedBounds.max.x, modifiedBounds.min.y );*
    
  •  _raycastOrigins.bottomLeft = modifiedBounds.min;*
    
  • }*

  • ///

    *

  • /// we have to use a bit of trickery in this one. The rays must be cast from a small distance inside of our*

  • /// collider (skinWidth) to avoid zero distance rays which will get the wrong normal. Because of this small offset*

  • /// we have to increase the ray distance skinWidth then remember to remove skinWidth from deltaMovement before*

  • /// actually moving the player*

  • /// *

  • private void moveHorizontally( ref Vector3 deltaMovement )*

  • {*

  •  var isGoingRight = deltaMovement.x > 0;*
    
  •  var rayDistance = Mathf.Abs( deltaMovement.x ) + _skinWidth;*
    
  •  var rayDirection = isGoingRight ? Vector2.right : -Vector2.right;*
    
  •  var initialRayOrigin = isGoingRight ? _raycastOrigins.bottomRight : _raycastOrigins.bottomLeft;*
    
  •  for( var i = 0; i < totalHorizontalRays; i++ )*
    
  •  {*
    

var ray = new Vector2( initialRayOrigin.x, initialRayOrigin.y + i * _verticalDistanceBetweenRays );

_ DrawRay( ray, rayDirection * rayDistance, Color.red );_

  •  	// if we are grounded we will include oneWayPlatforms only on the first ray (the bottom one). this will allow us to*
    
  •  	// walk up sloped oneWayPlatforms*
    
  •  	if( i == 0 && collisionState.wasGroundedLastFrame )*
    
  •  		_raycastHit = Physics2D.Raycast( ray, rayDirection, rayDistance, platformMask );*
    
  •  	else*
    
  •  		_raycastHit = Physics2D.Raycast( ray, rayDirection, rayDistance, platformMask & ~oneWayPlatformMask );*
    
  •  	if( _raycastHit )*
    
  •  	{*
    
  •  		// the bottom ray can hit slopes but no other ray can so we have special handling for those cases*
    
  •  		if( i == 0 && handleHorizontalSlope( ref deltaMovement, Vector2.Angle( _raycastHit.normal, Vector2.up ) ) )*
    
  •  		{*
    
  •  			_raycastHitsThisFrame.Add( _raycastHit );*
    
  •  			break;*
    
  •  		}*
    
  •  		// set our new deltaMovement and recalculate the rayDistance taking it into account*
    
  •  		deltaMovement.x = _raycastHit.point.x - ray.x;*
    
  •  		rayDistance = Mathf.Abs( deltaMovement.x );*
    
  •  		// remember to remove the skinWidth from our deltaMovement*
    
  •  		if( isGoingRight )*
    
  •  		{*
    
  •  			deltaMovement.x -= _skinWidth;*
    
  •  			collisionState.right = true;*
    
  •  		}*
    
  •  		else*
    
  •  		{*
    
  •  			deltaMovement.x += _skinWidth;*
    
  •  			collisionState.left = true;*
    
  •  		}*
    
  •  		_raycastHitsThisFrame.Add( _raycastHit );*
    
  •  		// we add a small fudge factor for the float operations here. if our rayDistance is smaller*
    
  •  		// than the width + fudge bail out because we have a direct impact*
    
  •  		if( rayDistance < _skinWidth + kSkinWidthFloatFudgeFactor )*
    
  •  			break;*
    
  •  	}*
    
  •  }*
    
  • }*

  • ///

    *

  • /// handles adjusting deltaMovement if we are going up a slope.*

  • /// *

  • /// true, if horizontal slope was handled, false otherwise.*

  • /// Delta movement.*

  • /// Angle.*

  • private bool handleHorizontalSlope( ref Vector3 deltaMovement, float angle )*

  • {*

  •  // disregard 90 degree angles (walls)*
    
  •  if( Mathf.RoundToInt( angle ) == 90 )*
    
  •  	return false;*
    
  •  // if we can walk on slopes and our angle is small enough we need to move up*
    
  •  if( angle < slopeLimit )*
    
  •  {*
    
  •  	// we only need to adjust the deltaMovement if we are not jumping*
    
  •  	// TODO: this uses a magic number which isn't ideal!*
    
  •  	if( deltaMovement.y < jumpingThreshold )*
    
  •  	{*
    
  •  		// apply the slopeModifier to slow our movement up the slope*
    
  •  		var slopeModifier = slopeSpeedMultiplier.Evaluate( angle );*
    

_ deltaMovement.x *= slopeModifier;_

  •  		// we dont set collisions on the sides for this since a slope is not technically a side collision*
    
  •  		// smooth y movement when we climb. we make the y movement equivalent to the actual y location that corresponds*
    
  •  		// to our new x location using our good friend Pythagoras*
    

_ deltaMovement.y = Mathf.Abs( Mathf.Tan( angle * Mathf.Deg2Rad ) * deltaMovement.x );_

  •  		_isGoingUpSlope = true;*
    
  •  		collisionState.below = true;*
    
  •  	}*
    
  •  }*
    
  •  else // too steep. get out of here*
    
  •  {*
    
  •  	deltaMovement.x = 0;*
    
  •  }*
    
  •  return true;*
    
  • }*

  • private void moveVertically( ref Vector3 deltaMovement )*

  • {*

  •  var isGoingUp = deltaMovement.y > 0;*
    
  •  var rayDistance = Mathf.Abs( deltaMovement.y ) + _skinWidth;*
    
  •  var rayDirection = isGoingUp ? Vector2.up : -Vector2.up;*
    
  •  var initialRayOrigin = isGoingUp ? _raycastOrigins.topLeft : _raycastOrigins.bottomLeft;*
    
  •  // apply our horizontal deltaMovement here so that we do our raycast from the actual position we would be in if we had moved*
    
  •  initialRayOrigin.x += deltaMovement.x;*
    
  •  // if we are moving up, we should ignore the layers in oneWayPlatformMask*
    
  •  var mask = platformMask;*
    
  •  if( isGoingUp && !collisionState.wasGroundedLastFrame )*
    
  •  	mask &= ~oneWayPlatformMask;*
    
  •  for( var i = 0; i < totalVerticalRays; i++ )*
    
  •  {*
    

var ray = new Vector2( initialRayOrigin.x + i * _horizontalDistanceBetweenRays, initialRayOrigin.y );

_ DrawRay( ray, rayDirection * rayDistance, Color.red );_

  •  	_raycastHit = Physics2D.Raycast( ray, rayDirection, rayDistance, mask );*
    
  •  	if( _raycastHit )*
    
  •  	{*
    
  •  		// set our new deltaMovement and recalculate the rayDistance taking it into account*
    
  •  		deltaMovement.y = _raycastHit.point.y - ray.y;*
    
  •  		rayDistance = Mathf.Abs( deltaMovement.y );*
    
  •  		// remember to remove the skinWidth from our deltaMovement*
    
  •  		if( isGoingUp )*
    
  •  		{*
    
  •  			deltaMovement.y -= _skinWidth;*
    
  •  			collisionState.above = true;*
    
  •  		}*
    
  •  		else*
    
  •  		{*
    
  •  			deltaMovement.y += _skinWidth;*
    
  •  			collisionState.below = true;*
    
  •  		}*
    
  •  		_raycastHitsThisFrame.Add( _raycastHit );*
    
  •  		// this is a hack to deal with the top of slopes. if we walk up a slope and reach the apex we can get in a situation*
    
  •  		// where our ray gets a hit that is less then skinWidth causing us to be ungrounded the next frame due to residual velocity.*
    
  •  		if( !isGoingUp && deltaMovement.y > 0.00001f )*
    
  •  			_isGoingUpSlope = true;*
    
  •  		// we add a small fudge factor for the float operations here. if our rayDistance is smaller*
    
  •  		// than the width + fudge bail out because we have a direct impact*
    
  •  		if( rayDistance < _skinWidth + kSkinWidthFloatFudgeFactor )*
    
  •  			return;*
    
  •  	}*
    
  •  }*
    
  • }*

  • ///

    *

  • /// checks the center point under the BoxCollider2D for a slope. If it finds one then the deltaMovement is adjusted so that*

  • /// the player stays grounded and the slopeSpeedModifier is taken into account to speed up movement.*

  • /// *

  • /// Delta movement.*

  • private void handleVerticalSlope( ref Vector3 deltaMovement )*

  • {*

  •  // slope check from the center of our collider*
    

var centerOfCollider = ( _raycastOrigins.bottomLeft.x + _raycastOrigins.bottomRight.x ) * 0.5f;

  •  var rayDirection = -Vector2.up;*
    
  •  // the ray distance is based on our slopeLimit*
    

var slopeCheckRayDistance = _slopeLimitTangent * ( _raycastOrigins.bottomRight.x - centerOfCollider );

  •  var slopeRay = new Vector2( centerOfCollider, _raycastOrigins.bottomLeft.y );*
    

_ DrawRay( slopeRay, rayDirection * slopeCheckRayDistance, Color.yellow );_

  •  _raycastHit = Physics2D.Raycast( slopeRay, rayDirection, slopeCheckRayDistance, platformMask );*
    
  •  if( _raycastHit )*
    
  •  {*
    
  •  	// bail out if we have no slope*
    
  •  	var angle = Vector2.Angle( _raycastHit.normal, Vector2.up );*
    
  •  	if( angle == 0 )*
    
  •  		return;*
    
  •  	// we are moving down the slope if our normal and movement direction are in the same x direction*
    
  •  	var isMovingDownSlope = Mathf.Sign( _raycastHit.normal.x ) == Mathf.Sign( deltaMovement.x );*
    
  •  	if( isMovingDownSlope )*
    
  •  	{*
    
  •  		// going down we want to speed up in most cases so the slopeSpeedMultiplier curve should be > 1 for negative angles*
    
  •  		var slopeModifier = slopeSpeedMultiplier.Evaluate( -angle );*
    
  •  		deltaMovement.y = _raycastHit.point.y - slopeRay.y - skinWidth;*
    

_ deltaMovement.x *= slopeModifier;_

  •  		collisionState.movingDownSlope = true;*
    
  •  		collisionState.slopeAngle = angle;*
    
  •  	}*
    
  •  }*
    
  • }*

  • #endregion*

}

Okay thank you for your answers,
alexi I have done as you said. I changed the gravity to go down the Z-Axis and I changed the movement for the characterController to go along the Y-Axis. I can now move, but still my character runs through walls. Those wall prefabs all have a BoxCollider2D.
What I now want to ask is: Do I still have to do something? What I unserstand as “collision detection” is that the controller sees “oh there is an Object with a BoxCollider, I won’t move into that”, but he behaves more like “OH there is an object with a BoxCollider, I DONT CARE”. But I think I did not get that right yet.
I also read some things about the OnControllerColliderHit methods and the OnCollisionEnter methods, but I tried both, tried debugging, and the controller did not call those functions as I moved into the wall. Also I don’t know where to put them (in the player script that has the controller game object, or in the wall script?!), and how to stop my character to run into the wall when the collision is detected.
Sorry to ask such stupid things but I really have no idea :slight_smile:

Greetings,
Sym

Maybe a quick and dirty solution: Just freeze the Z axis (or whichever appropriate translate direction) on your controllers rigidbody component.