Creating an Advanced 2D Platformer Player Controller in Godot with C
Building a responsive and feature-rich player controller is crucial for any 2D platformer. In this comprehensive tutorial, we'll create a professional-grade player controller in Godot using C# that includes advanced movement mechanics like wall jumping, double jumping, wall sliding, and climbing. Our controller will also be structured for easy expansion with future features like combat and shooting.
What You'll Learn
By the end of this tutorial, you'll have a complete player controller that supports:
- Dual sprite system (separate upper and lower body animations)
- Smooth horizontal movement with proper air control
- Jump and double jump mechanics
- Wall hanging and sliding with realistic physics
- Wall jumping with directional force
- Climbing system
- Automatic sprite flipping based on movement direction
- Extensible architecture for adding new abilities
Prerequisites
- Godot 4.x with C# support enabled
- Basic understanding of C# programming
- Familiarity with Godot's node system and scenes
- Understanding of 2D physics concepts
Step 1: Setting Up the Project
1.1 Create a New Godot Project
- Open Godot and create a new project
- Ensure C# support is enabled (you'll need .NET SDK installed)
- Set up your project settings for 2D development
1.2 Configure Input Map
Navigate to Project → Project Settings → Input Map and add the following actions:
move_left
(assign A key and Left arrow)move_right
(assign D key and Right arrow)jump
(assign Space key)move_up
(assign W key and Up arrow)move_down
(assign S key and Down arrow)
Step 2: Creating the Player Scene
2.1 Scene Structure
Create a new scene and set up the following node hierarchy:
Player (CharacterBody2D)
├── CollisionShape2D
├── UpperBodySprite (AnimatedSprite2D)
└── LowerBodySprite (AnimatedSprite2D)
2.2 Configure the Collision Shape
- Select the
CollisionShape2D
node - In the Inspector, create a new
CapsuleShape2D
- Adjust the capsule size to match your player sprite dimensions
- Position it appropriately relative to your sprites
2.3 Set Up Sprite Frames
For both UpperBodySprite
and LowerBodySprite
:
- Create new
SpriteFrames
resources - Add the following animations:
idle
- Standing still animationmove
- Walking/running animationjump
- Jump start animationfall
- Falling animationwallhang
- Hanging on wall animationwallslide
- Sliding down wall animationclimb
- Climbing animation
Pro Tip: You can create placeholder animations with simple colored rectangles to test the system before adding final artwork.
Step 3: Implementing the Player Controller Script
3.1 Create the Script
- Right-click on the
Player
node - Select Attach Script
- Choose C# as the language
- Name it
Player.cs
3.2 The Complete Player Controller Code
Replace the default script content with the following comprehensive player controller:
using Godot;
public partial class Player : CharacterBody2D
{
// Node references
[Export] public AnimatedSprite2D UpperBodySprite { get; set; }
[Export] public AnimatedSprite2D LowerBodySprite { get; set; }
[Export] public CollisionShape2D CollisionShape { get; set; }
// Movement constants
[Export] public float Speed { get; set; } = 300.0f;
[Export] public float JumpVelocity { get; set; } = -400.0f;
[Export] public float WallJumpVelocity { get; set; } = -350.0f;
[Export] public float WallJumpHorizontalForce { get; set; } = 200.0f;
[Export] public float WallHangTime { get; set; } = 2.0f;
[Export] public float WallSlideSpeed { get; set; } = 100.0f;
[Export] public float ClimbSpeed { get; set; } = 150.0f;
// State variables
private PlayerState _currentState = PlayerState.Idle;
private bool _facingRight = true;
private int _jumpCount = 0;
private const int MaxJumps = 2;
private float _wallHangTimer = 0.0f;
private bool _canWallJump = false;
private bool _isWallSliding = false;
// Input flags
private bool _inputLeft = false;
private bool _inputRight = false;
private bool _inputJump = false;
private bool _inputJumpPressed = false;
private bool _inputUp = false;
private bool _inputDown = false;
// Physics
private float _gravity = ProjectSettings.GetSetting("physics/2d/default_gravity").AsSingle();
public enum PlayerState
{
Idle,
Moving,
Jumping,
Falling,
WallHanging,
WallSliding,
Climbing,
// Future states for extensibility
MeleeAttack,
Shooting
}
public enum AnimationType
{
Idle,
Move,
Jump,
Fall,
WallHang,
WallSlide,
Climb,
// Future animations
MeleeAttack,
Shoot
}
public override void _Ready()
{
// Ensure sprites are properly assigned
if (UpperBodySprite == null)
UpperBodySprite = GetNode<AnimatedSprite2D>("UpperBodySprite");
if (LowerBodySprite == null)
LowerBodySprite = GetNode<AnimatedSprite2D>("LowerBodySprite");
if (CollisionShape == null)
CollisionShape = GetNode<CollisionShape2D>("CollisionShape2D");
// Initialize state
UpdateAnimation(AnimationType.Idle);
}
public override void _PhysicsProcess(double delta)
{
HandleInput();
UpdateState(delta);
ApplyMovement(delta);
UpdateAnimation();
MoveAndSlide();
}
private void HandleInput()
{
_inputLeft = Input.IsActionPressed("move_left");
_inputRight = Input.IsActionPressed("move_right");
_inputJump = Input.IsActionPressed("jump");
_inputJumpPressed = Input.IsActionJustPressed("jump");
_inputUp = Input.IsActionPressed("move_up");
_inputDown = Input.IsActionPressed("move_down");
}
private void UpdateState(double delta)
{
bool wasOnFloor = IsOnFloor();
bool isOnWall = IsOnWall();
// Reset jump count when on floor
if (IsOnFloor())
{
_jumpCount = 0;
_canWallJump = false;
_isWallSliding = false;
_wallHangTimer = 0.0f;
}
// Handle wall interactions
if (isOnWall && !IsOnFloor())
{
HandleWallInteraction(delta);
}
else
{
_wallHangTimer = 0.0f;
_canWallJump = false;
_isWallSliding = false;
}
// Determine current state based on conditions
switch (_currentState)
{
case PlayerState.Idle:
case PlayerState.Moving:
if (!IsOnFloor())
{
if (Velocity.Y < 0)
_currentState = PlayerState.Jumping;
else
_currentState = PlayerState.Falling;
}
else if (_inputLeft || _inputRight)
{
_currentState = PlayerState.Moving;
}
else
{
_currentState = PlayerState.Idle;
}
break;
case PlayerState.Jumping:
case PlayerState.Falling:
if (IsOnFloor())
{
if (_inputLeft || _inputRight)
_currentState = PlayerState.Moving;
else
_currentState = PlayerState.Idle;
}
else if (_currentState == PlayerState.Jumping && Velocity.Y >= 0)
{
_currentState = PlayerState.Falling;
}
break;
case PlayerState.WallHanging:
case PlayerState.WallSliding:
if (!isOnWall || IsOnFloor())
{
if (IsOnFloor())
{
if (_inputLeft || _inputRight)
_currentState = PlayerState.Moving;
else
_currentState = PlayerState.Idle;
}
else
{
_currentState = PlayerState.Falling;
}
}
break;
case PlayerState.Climbing:
if (!isOnWall)
{
_currentState = PlayerState.Falling;
}
else if (IsOnFloor())
{
_currentState = PlayerState.Idle;
}
break;
}
}
private void HandleWallInteraction(double delta)
{
_canWallJump = true;
// Check if player is trying to climb
if (_inputUp && CanClimb())
{
_currentState = PlayerState.Climbing;
return;
}
// Handle wall hanging and sliding
if (Velocity.Y >= 0) // Only when falling or stationary
{
if (_wallHangTimer < WallHangTime)
{
_currentState = PlayerState.WallHanging;
_wallHangTimer += (float)delta;
}
else
{
_currentState = PlayerState.WallSliding;
_isWallSliding = true;
}
}
}
private void ApplyMovement(double delta)
{
switch (_currentState)
{
case PlayerState.Idle:
case PlayerState.Moving:
HandleGroundMovement();
ApplyGravity(delta);
HandleJump();
break;
case PlayerState.Jumping:
case PlayerState.Falling:
HandleAirMovement();
ApplyGravity(delta);
HandleDoubleJump();
HandleWallJump();
break;
case PlayerState.WallHanging:
HandleWallHang();
HandleWallJump();
break;
case PlayerState.WallSliding:
HandleWallSlide(delta);
HandleWallJump();
break;
case PlayerState.Climbing:
HandleClimbing();
break;
}
// Update facing direction
UpdateFacingDirection();
}
private void HandleGroundMovement()
{
Vector2 velocity = Velocity;
if (_inputLeft)
{
velocity.X = -Speed;
}
else if (_inputRight)
{
velocity.X = Speed;
}
else
{
velocity.X = Mathf.MoveToward(velocity.X, 0, Speed * 0.1f);
}
Velocity = velocity;
}
private void HandleAirMovement()
{
Vector2 velocity = Velocity;
if (_inputLeft)
{
velocity.X = -Speed * 0.8f; // Reduced air control
}
else if (_inputRight)
{
velocity.X = Speed * 0.8f;
}
Velocity = velocity;
}
private void HandleJump()
{
if (_inputJumpPressed && IsOnFloor())
{
Vector2 velocity = Velocity;
velocity.Y = JumpVelocity;
Velocity = velocity;
_jumpCount = 1;
}
}
private void HandleDoubleJump()
{
if (_inputJumpPressed && _jumpCount < MaxJumps && !_canWallJump)
{
Vector2 velocity = Velocity;
velocity.Y = JumpVelocity;
Velocity = velocity;
_jumpCount++;
}
}
private void HandleWallJump()
{
if (_inputJumpPressed && _canWallJump)
{
Vector2 velocity = Velocity;
velocity.Y = WallJumpVelocity;
// Determine wall jump direction
if (IsOnWallOnly())
{
int wallDirection = GetWallNormal().X > 0 ? 1 : -1;
velocity.X = wallDirection * WallJumpHorizontalForce;
}
Velocity = velocity;
_jumpCount = 1;
_canWallJump = false;
_wallHangTimer = 0.0f;
}
}
private void HandleWallHang()
{
Vector2 velocity = Velocity;
velocity.Y = 0; // Stop falling while hanging
Velocity = velocity;
}
private void HandleWallSlide(double delta)
{
Vector2 velocity = Velocity;
// Gradually increase slide speed from 0 to WallSlideSpeed
float slideProgress = (_wallHangTimer - WallHangTime) / (WallSlideSpeed / _gravity);
slideProgress = Mathf.Clamp(slideProgress, 0.0f, 1.0f);
velocity.Y = Mathf.Lerp(0, WallSlideSpeed, slideProgress);
Velocity = velocity;
}
private void HandleClimbing()
{
Vector2 velocity = Velocity;
if (_inputUp)
{
velocity.Y = -ClimbSpeed;
}
else if (_inputDown)
{
velocity.Y = ClimbSpeed;
}
else
{
velocity.Y = 0;
}
velocity.X = 0;
Velocity = velocity;
}
private void ApplyGravity(double delta)
{
Vector2 velocity = Velocity;
if (!IsOnFloor())
{
velocity.Y += _gravity * (float)delta;
}
Velocity = velocity;
}
private void UpdateFacingDirection()
{
if (_inputRight && !_facingRight)
{
_facingRight = true;
FlipSprites(false);
}
else if (_inputLeft && _facingRight)
{
_facingRight = false;
FlipSprites(true);
}
}
private void FlipSprites(bool flip)
{
UpperBodySprite.FlipH = flip;
LowerBodySprite.FlipH = flip;
}
private void UpdateAnimation(AnimationType? forceAnimation = null)
{
AnimationType targetAnimation;
if (forceAnimation.HasValue)
{
targetAnimation = forceAnimation.Value;
}
else
{
targetAnimation = _currentState switch
{
PlayerState.Idle => AnimationType.Idle,
PlayerState.Moving => AnimationType.Move,
PlayerState.Jumping => AnimationType.Jump,
PlayerState.Falling => AnimationType.Fall,
PlayerState.WallHanging => AnimationType.WallHang,
PlayerState.WallSliding => AnimationType.WallSlide,
PlayerState.Climbing => AnimationType.Climb,
_ => AnimationType.Idle
};
}
PlayAnimation(targetAnimation);
}
private void PlayAnimation(AnimationType animation)
{
string animationName = animation.ToString().ToLower();
// Play animation on both sprites
if (UpperBodySprite.SpriteFrames.HasAnimation(animationName))
{
UpperBodySprite.Play(animationName);
}
if (LowerBodySprite.SpriteFrames.HasAnimation(animationName))
{
LowerBodySprite.Play(animationName);
}
}
private bool CanClimb()
{
// Override this method to add climb condition logic (e.g., ladder detection)
return IsOnWall(); // Simple implementation - can climb any wall
}
// Utility methods for future extensibility
protected virtual void OnActionStarted(PlayerState action)
{
// Override in derived classes or use signals for action events
}
protected virtual void OnActionEnded(PlayerState action)
{
// Override in derived classes or use signals for action events
}
protected virtual bool CanPerformAction(PlayerState action)
{
// Override to add action-specific conditions
return true;
}
// Public methods for external systems
public PlayerState GetCurrentState() => _currentState;
public bool IsFacingRight() => _facingRight;
public bool IsWallSliding() => _isWallSliding;
public float GetWallHangTimeRemaining() => Mathf.Max(0, WallHangTime - _wallHangTimer);
}
Step 4: Configuring the Inspector Properties
After attaching the script, configure the exported properties in the Inspector:
4.1 Node References
- UpperBodySprite: Drag your UpperBodySprite node here
- LowerBodySprite: Drag your LowerBodySprite node here
- CollisionShape: Drag your CollisionShape2D node here
4.2 Movement Parameters
Adjust these values to fine-tune your player's feel:
- Speed:
300
(horizontal movement speed) - Jump Velocity:
-400
(initial jump force, negative for upward) - Wall Jump Velocity:
-350
(wall jump upward force) - Wall Jump Horizontal Force:
200
(horizontal push from wall) - Wall Hang Time:
2.0
(seconds before sliding starts) - Wall Slide Speed:
100
(maximum slide speed) - Climb Speed:
150
(vertical climbing speed)
Step 5: Creating a Test Level
5.1 Basic Level Setup
Create a new scene for testing:
Main (Node2D)
├── Player (instance of your player scene)
└── Level (Node2D)
├── Ground (StaticBody2D)
│ ├── CollisionShape2D (RectangleShape2D)
│ └── Sprite2D
└── Wall (StaticBody2D)
├── CollisionShape2D (RectangleShape2D)
└── Sprite2D
5.2 Testing the Controller
Run your scene and test the following mechanics:
- Basic Movement: A/D keys for left/right movement
- Jumping: Space for jump and double jump
- Wall Mechanics: Run toward a wall and test hanging/sliding
- Wall Jumping: Jump while against a wall
- Climbing: Hold W while against a wall
- Sprite Flipping: Verify sprites flip when changing direction
Step 6: Understanding the Architecture
6.1 State Management System
The controller uses a comprehensive state system:
- PlayerState enum: Defines all possible player states
- State transitions: Handled in
UpdateState()
method - State-specific behavior: Each state has its own movement logic
6.2 Animation System
The dual-sprite animation system provides flexibility:
- AnimationType enum: Maps to sprite frame animations
- Synchronized playback: Both sprites play the same animation
- Easy expansion: Add new animation types for future features
6.3 Input Handling
Clean separation of input detection and action execution:
- Input flags: Updated each frame in
HandleInput()
- Action methods: Process inputs based on current state
- Extensible: Easy to add new input actions
Step 7: Customization and Extension
7.1 Adjusting Game Feel
Fine-tune these aspects for your game's unique feel:
- Air Control: Modify the
0.8f
multiplier inHandleAirMovement()
- Ground Friction: Adjust the
0.1f
value inHandleGroundMovement()
- Wall Slide Progression: Modify the calculation in
HandleWallSlide()
7.2 Adding New States
To add combat or other features:
- Add new states to
PlayerState
enum - Add corresponding animations to
AnimationType
enum - Implement state-specific logic in
ApplyMovement()
- Add state transitions in
UpdateState()
7.3 Enhanced Climbing System
For more sophisticated climbing (ladders, specific surfaces):
private bool CanClimb()
{
// Check for climbable objects using area detection
var spaceState = GetWorld2D().DirectSpaceState;
var query = PhysicsRayQueryParameters2D.Create(
GlobalPosition,
GlobalPosition + Vector2.Right * 10
);
var result = spaceState.IntersectRay(query);
if (result.Count > 0)
{
var collider = result["collider"].AsGodotObject();
return collider.HasMethod("IsClimbable") &&
collider.Call("IsClimbable").AsBool();
}
return false;
}
Troubleshooting Common Issues
Animation Not Playing
- Ensure animation names in SpriteFrames match the lowercase enum values
- Check that both upper and lower body sprites have the required animations
- Verify the sprites are properly assigned in the Inspector
Wall Detection Issues
- Ensure walls have
StaticBody2D
orRigidBody2D
nodes - Check collision layers and masks are properly configured
- Test with simple rectangular collision shapes first
Movement Feels Sluggish
- Increase the
Speed
property - Reduce air control multiplier for more responsive air movement
- Adjust gravity in Project Settings if jumps feel too floaty
Performance Considerations
This controller is optimized for performance:
- Minimal allocations: Reuses Vector2 structures
- Efficient state checking: Uses boolean flags instead of repeated method calls
- Predictable frame rate: All operations are O(1) complexity
Conclusion
You now have a professional-grade 2D platformer controller that provides:
- Smooth, responsive movement with proper physics
- Advanced wall mechanics including hanging, sliding, and jumping
- Dual-sprite animation system for complex character animations
- Clean, extensible architecture ready for additional features
- Comprehensive state management for predictable behavior
This controller serves as an excellent foundation for any 2D platformer project. The modular design makes it easy to add new abilities like combat, special moves, or power-ups without restructuring the core system.
Next Steps
Consider extending this controller with:
- Combat System: Add melee and ranged attack states
- Power-ups: Implement temporary ability modifications
- Sound Integration: Add audio cues for actions and state changes
- Visual Effects: Integrate particle systems for enhanced feedback
- Advanced Movement: Add mechanics like dashing, sliding, or grappling
The foundation you've built here will support all of these additions while maintaining clean, readable code structure.