Developing a 2D side-scrolling game in Unity requires a well-functioning player movement system to provide a smooth and enjoyable gaming experience. This comprehensive guide will walk you through creating a robust player movement script using C#, covering horizontal movement, jumping mechanics, and ground detection. By the end of this tutorial, you'll have a solid foundation upon which you can build and expand your game's movement features.
Start by launching Unity and creating a new project:
To visualize the player, add a sprite to your scene:
Player
.Sprite Renderer
component (you can use the default square or import a custom sprite).To enable physics-based movement, add necessary components to the player:
Player
GameObject.Rigidbody2D
component.Body Type
is set to Dynamic.BoxCollider2D
or CircleCollider2D
for collision detection.Now, create the script that will handle player movement:
Assets
folder.PlayerMovement
.Replace the default code with the following comprehensive script:
// PlayerMovement.cs
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f; // Horizontal movement speed
public float jumpForce = 12f; // Upward jump force
[Header("Ground Detection Settings")]
public Transform groundCheck; // Empty GameObject to check ground
public float groundCheckRadius = 0.2f; // Radius for ground detection
public LayerMask groundLayer; // Layer for ground objects
private Rigidbody2D rb; // Rigidbody2D component
private bool isGrounded; // Whether the player is on the ground
private float moveInput; // Horizontal input
private void Start()
{
// Initialize Rigidbody2D component
rb = GetComponent<Rigidbody2D>();
}
private void Update()
{
// Get horizontal input
moveInput = Input.GetAxisRaw("Horizontal");
// Check for jump input
if (Input.GetButtonDown("Jump") && isGrounded)
{
Jump();
}
}
private void FixedUpdate()
{
// Handle horizontal movement
rb.velocity = new Vector2(moveInput * moveSpeed, rb.velocity.y);
// Check if the player is grounded
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
}
private void Jump()
{
// Apply jump force
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
}
// Optional: Visualize ground check in the editor
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
The script includes:
Physics2D.OverlapCircle
to detect if the player is on the ground.Update
and FixedUpdate
methods for smooth physics interactions.OnDrawGizmosSelected
helps visualize the ground check area in the Unity Editor.Player
GameObject in the Hierarchy.PlayerMovement
script onto the Inspector panel.Ground detection ensures that the player can only jump when touching the ground:
Player
:
Player
in the Hierarchy and select Create Empty.GroundCheck
.GroundCheck
to the groundCheck
field in the PlayerMovement
script.Ground
.Ground
layer to all ground-related GameObjects (e.g., platforms, terrain).PlayerMovement
script, set the Ground Layer
to the Ground
layer.Ensure the Rigidbody2D
is set up correctly:
Player
GameObject.Rigidbody2D
component.Gravity Scale
to a value that feels natural (e.g., 3).Freeze Rotation Z
is enabled to prevent the player from rotating.For more realistic movement, implement acceleration and deceleration:
// Enhanced PlayerMovement.cs
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float acceleration = 10f;
public float deceleration = 10f;
public float jumpForce = 12f;
[Header("Ground Detection Settings")]
public Transform groundCheck;
public float groundCheckRadius = 0.2f;
public LayerMask groundLayer;
private Rigidbody2D rb;
private bool isGrounded;
private float moveInput;
private void Start()
{
rb = GetComponent<Rigidbody2D>();
}
private void Update()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump") && isGrounded)
{
Jump();
}
}
private void FixedUpdate()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Limit the maximum speed
if (Mathf.Abs(rb.velocity.x) > moveSpeed)
{
rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
This enhancement introduces:
moveSpeed
.To make jumping more responsive and forgiving, add a jump buffer and coyote time:
// Enhanced PlayerMovement with Jump Buffer and Coyote Time
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float acceleration = 10f;
public float deceleration = 10f;
public float jumpForce = 12f;
[Header("Ground Detection Settings")]
public Transform groundCheck;
public float groundCheckRadius = 0.2f;
public LayerMask groundLayer;
[Header("Jump Settings")]
public float jumpBufferTime = 0.2f;
public float coyoteTime = 0.2f;
private Rigidbody2D rb;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private void Start()
{
rb = GetComponent<Rigidbody2D>();
}
private void Update()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
}
private void FixedUpdate()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
// Jump logic with buffer and coyote time
if (jumpBufferCounter > 0f && coyoteCounter > 0f)
{
Jump();
jumpBufferCounter = 0f;
coyoteCounter = 0f;
}
// Movement
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Limit the maximum speed
if (Mathf.Abs(rb.velocity.x) > moveSpeed)
{
rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
This addition provides:
To make the player face the direction of movement, implement character flipping:
// Enhanced PlayerMovement with Character Flipping
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float acceleration = 10f;
public float deceleration = 10f;
public float jumpForce = 12f;
[Header("Ground Detection Settings")]
public Transform groundCheck;
public float groundCheckRadius = 0.2f;
public LayerMask groundLayer;
[Header("Jump Settings")]
public float jumpBufferTime = 0.2f;
public float coyoteTime = 0.2f;
private Rigidbody2D rb;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private void Start()
{
rb = GetComponent<Rigidbody2D>();
}
private void Update()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
// Handle character flipping
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
}
private void FixedUpdate()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
// Jump logic with buffer and coyote time
if (jumpBufferCounter > 0f && coyoteCounter > 0f)
{
Jump();
jumpBufferCounter = 0f;
coyoteCounter = 0f;
}
// Movement
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Limit the maximum speed
if (Mathf.Abs(rb.velocity.x) > moveSpeed)
{
rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
Features added:
isFacingRight
boolean.Integrate animations to enhance visual feedback:
Animator Controller
by right-clicking in the Project window and selecting Create > Animator Controller.PlayerAnimator
.Animator
component on the Player
.Idle
, Run
, and Jump
.Animator
component.// Enhanced PlayerMovement with Animation
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
public float moveSpeed = 5f;
public float acceleration = 10f;
public float deceleration = 10f;
public float jumpForce = 12f;
[Header("Ground Detection Settings")]
public Transform groundCheck;
public float groundCheckRadius = 0.2f;
public LayerMask groundLayer;
[Header("Jump Settings")]
public float jumpBufferTime = 0.2f;
public float coyoteTime = 0.2f;
private Rigidbody2D rb;
private Animator anim;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private void Start()
{
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
private void Update()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
// Handle character flipping
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
// Update Animator parameters
anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
anim.SetBool("isGrounded", isGrounded);
}
private void FixedUpdate()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
// Jump logic with buffer and coyote time
if (jumpBufferCounter > 0f && coyoteCounter > 0f)
{
Jump();
jumpBufferCounter = 0f;
coyoteCounter = 0f;
}
// Movement
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Limit the maximum speed
if (Mathf.Abs(rb.velocity.x) > moveSpeed)
{
rb.velocity = new Vector2(Mathf.Sign(rb.velocity.x) * moveSpeed, rb.velocity.y);
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
anim.SetTrigger("Jump");
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
Additional features:
Animator
component to control animations.Speed
and isGrounded
parameters to switch between animations.SetTrigger("Jump")
to initiate the jump animation.Ensure your Animator Controller is set up with the necessary parameters and transitions:
PlayerAnimator
in the Animator window.Idle
, Run
, and Jump
animation states.Speed
(float) and isGrounded
(bool) as parameters in the Animator.Idle
to Run
when Speed
> 0.1.Run
to Idle
when Speed
< 0.1.Jump
when Jump
trigger is set.Jump
back to Idle
or Run
based on isGrounded
.Refactor the code to enhance readability and maintainability:
// Optimized PlayerMovement.cs
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float acceleration = 10f;
[SerializeField] private float deceleration = 10f;
[SerializeField] private float jumpForce = 12f;
[Header("Ground Detection Settings")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckRadius = 0.2f;
[SerializeField] private LayerMask groundLayer;
[Header("Jump Settings")]
[SerializeField] private float jumpBufferTime = 0.2f;
[SerializeField] private float coyoteTime = 0.2f;
private Rigidbody2D rb;
private Animator anim;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
private void Update()
{
HandleInput();
UpdateAnimations();
}
private void FixedUpdate()
{
CheckGroundStatus();
HandleMovement();
HandleJump();
}
private void HandleInput()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
HandleCharacterFlip();
}
private void HandleCharacterFlip()
{
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
}
private void CheckGroundStatus()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
}
private void HandleMovement()
{
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Clamp the velocity
rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
}
private void HandleJump()
{
if (jumpBufferCounter > 0f && coyoteCounter > 0f)
{
Jump();
jumpBufferCounter = 0f;
coyoteCounter = 0f;
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
anim.SetTrigger("Jump");
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void UpdateAnimations()
{
anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
anim.SetBool("isGrounded", isGrounded);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
Improvements made:
Rigidbody2D
and Animator
are present.Introduce a dash ability to add depth to player movement:
// PlayerMovement with Dashing
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float acceleration = 10f;
[SerializeField] private float deceleration = 10f;
[SerializeField] private float jumpForce = 12f;
[Header("Ground Detection Settings")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckRadius = 0.2f;
[SerializeField] private LayerMask groundLayer;
[Header("Jump Settings")]
[SerializeField] private float jumpBufferTime = 0.2f;
[SerializeField] private float coyoteTime = 0.2f;
[Header("Dash Settings")]
[SerializeField] private float dashSpeed = 20f;
[SerializeField] private float dashDuration = 0.2f;
[SerializeField] private float dashCooldown = 1f;
private Rigidbody2D rb;
private Animator anim;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private bool isDashing = false;
private float dashTimer;
private float dashCooldownTimer;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
private void Update()
{
HandleInput();
UpdateAnimations();
if (isDashing)
{
return;
}
if (dashCooldownTimer > 0)
{
dashCooldownTimer -= Time.deltaTime;
}
}
private void FixedUpdate()
{
if (isDashing)
{
rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
dashTimer -= Time.fixedDeltaTime;
if (dashTimer <= 0)
{
isDashing = false;
}
return;
}
CheckGroundStatus();
HandleMovement();
HandleJump();
}
private void HandleInput()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
{
Dash();
}
HandleCharacterFlip();
}
private void HandleCharacterFlip()
{
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
}
private void CheckGroundStatus()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
}
private void HandleMovement()
{
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Clamp the velocity
rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
}
private void HandleJump()
{
if (jumpBufferCounter > 0f && coyoteCounter > 0f)
{
Jump();
jumpBufferCounter = 0f;
coyoteCounter = 0f;
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
anim.SetTrigger("Jump");
}
private void Dash()
{
isDashing = true;
dashTimer = dashDuration;
dashCooldownTimer = dashCooldown;
anim.SetTrigger("Dash");
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void UpdateAnimations()
{
anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
anim.SetBool("isGrounded", isGrounded);
anim.SetBool("isDashing", isDashing);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
New additions:
Ensure your scene has ground objects for the player to interact with:
Ground
.BoxCollider2D
to enable collision.Ground
layer to this GameObject.Configure the player's movement parameters in the Inspector:
Move Speed
to 5
.Jump Force
to 12
.Ground Check Radius
to 0.2
.Run the game and test the player controls:
If the player isn't behaving as expected, check the following:
GroundCheck
is correctly positioned at the player's feet.Ground
layer.moveSpeed
, jumpForce
, and groundCheckRadius
.Add the ability for the player to perform a second jump while airborne:
// PlayerMovement with Double Jump
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float acceleration = 10f;
[SerializeField] private float deceleration = 10f;
[SerializeField] private float jumpForce = 12f;
[SerializeField] private int maxJumpCount = 2;
[Header("Ground Detection Settings")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckRadius = 0.2f;
[SerializeField] private LayerMask groundLayer;
[Header("Jump Settings")]
[SerializeField] private float jumpBufferTime = 0.2f;
[SerializeField] private float coyoteTime = 0.2f;
[Header("Dash Settings")]
[SerializeField] private float dashSpeed = 20f;
[SerializeField] private float dashDuration = 0.2f;
[SerializeField] private float dashCooldown = 1f;
private Rigidbody2D rb;
private Animator anim;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private bool isDashing = false;
private float dashTimer;
private float dashCooldownTimer;
private int jumpCount;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
private void Update()
{
HandleInput();
UpdateAnimations();
if (isDashing)
{
return;
}
if (dashCooldownTimer > 0)
{
dashCooldownTimer -= Time.deltaTime;
}
}
private void FixedUpdate()
{
if (isDashing)
{
rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
dashTimer -= Time.fixedDeltaTime;
if (dashTimer <= 0)
{
isDashing = false;
}
return;
}
CheckGroundStatus();
HandleMovement();
HandleJump();
}
private void HandleInput()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
{
Dash();
}
HandleCharacterFlip();
}
private void HandleCharacterFlip()
{
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
}
private void CheckGroundStatus()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
jumpCount = 0;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
}
private void HandleMovement()
{
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Clamp the velocity
rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
}
private void HandleJump()
{
if (jumpBufferCounter > 0f && (coyoteCounter > 0f || jumpCount < maxJumpCount))
{
Jump();
jumpBufferCounter = 0f;
if (!isGrounded)
{
jumpCount++;
}
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
anim.SetTrigger("Jump");
}
private void Dash()
{
isDashing = true;
dashTimer = dashDuration;
dashCooldownTimer = dashCooldown;
anim.SetTrigger("Dash");
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void UpdateAnimations()
{
anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
anim.SetBool("isGrounded", isGrounded);
anim.SetBool("isDashing", isDashing);
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
Enhancements include:
Allow the player to control jump height based on how long the jump button is held:
// PlayerMovement with Variable Jump Heights
using UnityEngine;
[RequireComponent(typeof(Rigidbody2D), typeof(Animator))]
public class PlayerMovement : MonoBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float acceleration = 10f;
[SerializeField] private float deceleration = 10f;
[SerializeField] private float jumpForce = 12f;
[SerializeField] private float lowJumpMultiplier = 2f;
[SerializeField] private float fallMultiplier = 2.5f;
[Header("Ground Detection Settings")]
[SerializeField] private Transform groundCheck;
[SerializeField] private float groundCheckRadius = 0.2f;
[SerializeField] private LayerMask groundLayer;
[Header("Jump Settings")]
[SerializeField] private float jumpBufferTime = 0.2f;
[SerializeField] private float coyoteTime = 0.2f;
[Header("Dash Settings")]
[SerializeField] private float dashSpeed = 20f;
[SerializeField] private float dashDuration = 0.2f;
[SerializeField] private float dashCooldown = 1f;
private Rigidbody2D rb;
private Animator anim;
private bool isGrounded;
private float moveInput;
private float jumpBufferCounter;
private float coyoteCounter;
private bool isFacingRight = true;
private bool isDashing = false;
private float dashTimer;
private float dashCooldownTimer;
private int jumpCount;
[SerializeField] private int maxJumpCount = 2;
private void Awake()
{
rb = GetComponent<Rigidbody2D>();
anim = GetComponent<Animator>();
}
private void Update()
{
HandleInput();
UpdateAnimations();
if (isDashing)
{
return;
}
if (dashCooldownTimer > 0)
{
dashCooldownTimer -= Time.deltaTime;
}
HandleVariableJump();
}
private void FixedUpdate()
{
if (isDashing)
{
rb.velocity = new Vector2(transform.localScale.x * dashSpeed, 0);
dashTimer -= Time.fixedDeltaTime;
if (dashTimer <= 0)
{
isDashing = false;
}
return;
}
CheckGroundStatus();
HandleMovement();
HandleJump();
}
private void HandleInput()
{
moveInput = Input.GetAxisRaw("Horizontal");
if (Input.GetButtonDown("Jump"))
{
jumpBufferCounter = jumpBufferTime;
}
else
{
jumpBufferCounter -= Time.deltaTime;
}
if (Input.GetKeyDown(KeyCode.LeftShift) && dashCooldownTimer <= 0f)
{
Dash();
}
HandleCharacterFlip();
}
private void HandleCharacterFlip()
{
if (moveInput > 0 && !isFacingRight)
{
Flip();
}
else if (moveInput < 0 && isFacingRight)
{
Flip();
}
}
private void CheckGroundStatus()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, groundLayer);
if (isGrounded)
{
coyoteCounter = coyoteTime;
jumpCount = 0;
}
else
{
coyoteCounter -= Time.fixedDeltaTime;
}
}
private void HandleMovement()
{
float targetSpeed = moveInput * moveSpeed;
float speedDifference = targetSpeed - rb.velocity.x;
float accelRate = (Mathf.Abs(targetSpeed) > 0.01f) ? acceleration : deceleration;
float movement = Mathf.Pow(Mathf.Abs(speedDifference) * accelRate, 0.9f) * Mathf.Sign(speedDifference);
rb.AddForce(movement * Vector2.right);
// Clamp the velocity
rb.velocity = new Vector2(Mathf.Clamp(rb.velocity.x, -moveSpeed, moveSpeed), rb.velocity.y);
}
private void HandleJump()
{
if (jumpBufferCounter > 0f && (coyoteCounter > 0f || jumpCount < maxJumpCount))
{
Jump();
jumpBufferCounter = 0f;
if (!isGrounded)
{
jumpCount++;
}
}
}
private void Jump()
{
rb.velocity = new Vector2(rb.velocity.x, jumpForce);
anim.SetTrigger("Jump");
}
private void Dash()
{
isDashing = true;
dashTimer = dashDuration;
dashCooldownTimer = dashCooldown;
anim.SetTrigger("Dash");
}
private void Flip()
{
isFacingRight = !isFacingRight;
Vector3 scaler = transform.localScale;
scaler.x *= -1;
transform.localScale = scaler;
}
private void UpdateAnimations()
{
anim.SetFloat("Speed", Mathf.Abs(rb.velocity.x));
anim.SetBool("isGrounded", isGrounded);
anim.SetBool("isDashing", isDashing);
}
private void HandleVariableJump()
{
if (rb.velocity.y > 0 && !Input.GetButton("Jump"))
{
rb.velocity += Vector2.up * Physics2D.gravity.y * (lowJumpMultiplier - 1) * Time.deltaTime;
}
else if (rb.velocity.y < 0)
{
rb.velocity += Vector2.up * Physics2D.gravity.y * (fallMultiplier - 1) * Time.deltaTime;
}
}
private void OnDrawGizmosSelected()
{
if (groundCheck != null)
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
}
}
Enhancements include:
A smooth camera that follows the player enhances the gaming experience:
// CameraFollow.cs
using UnityEngine;
public class CameraFollow : MonoBehaviour
{
[Header("Target Settings")]
public Transform target;
public float smoothSpeed = 0.125f;
public Vector3 offset;
private void LateUpdate()
{
if (target == null)
return;
Vector3 desiredPosition = target.position + offset;
Vector3 smoothedPosition = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed);
transform.position = smoothedPosition;
}
}
Usage:
CameraFollow
.Main Camera
.Player
as the Target
.Offset
and Smooth Speed
in the Inspector for desired camera behavior.Ensure your movement script is optimized for better performance:
Design scripts to be modular for easier maintenance and scalability:
Thoroughly test all movement mechanics to ensure they work seamlessly:
Identify and fix any bugs or inconsistencies:
Finalize your game for deployment:
Creating a player movement script for a 2D side-scroller in Unity involves understanding physics interactions, handling user input, and implementing responsive controls. This comprehensive guide provided step-by-step instructions, from setting up the Unity project to writing and optimizing the movement script. By following these guidelines and continuously testing and refining your script, you can develop a fluid and engaging player movement system that enhances your game's overall experience. Remember to explore and integrate additional features like dashing, double jumping, and animations to further enrich your game's mechanics.