Unity CharacterController Bug: Getting Stuck on Small Edges

Unity Sprite Renderer Bug
Unity Sprite Renderer Bug: Sorting Layers Not Respecting Order
February 8, 2026
Coroutine Memory Leak
Unity Coroutine Memory Leak Bug: How to Identify and Fix Coroutine Reference Issues
February 9, 2026
Unity Sprite Renderer Bug
Unity Sprite Renderer Bug: Sorting Layers Not Respecting Order
February 8, 2026
Coroutine Memory Leak
Unity Coroutine Memory Leak Bug: How to Identify and Fix Coroutine Reference Issues
February 9, 2026

Unity CharacterController Bug: Getting Stuck on Small Edges

One of the most persistent and frustrating movement bugs in Unity is the CharacterController edge-sticking bug. This issue occurs when a CharacterController component becomes inexplicably stuck on small ledges, door thresholds, seams between floor tiles, or minor irregularities in level geometry—even when there appears to be enough clearance for the character to pass smoothly. Unlike physics-based movement with Rigidbody components, CharacterController movement is processed through Unity’s dedicated character movement system, which introduces its own unique set of edge cases and collision quirks.

Understanding the Bug: The Root Causes

The CharacterController edge-sticking bug emerges from several interacting factors within Unity’s character movement system:

1. Collider Skin Width and Slope Limit Interplay

CharacterController uses an internal “skin width” parameter that extends slightly beyond the visual collider bounds. This skin width helps maintain contact with surfaces for reliable collision detection, but it creates problems at edges:

// CharacterController settings that contribute to edge sticking:
CharacterController controller = GetComponent();
controller.skinWidth = 0.08f;      // Default value - can cause issues
controller.slopeLimit = 45f;       // Maximum climbable slope
controller.stepOffset = 0.3f;      // Maximum step height

// Problem: At an edge, the skin width extends downward and sideways
// creating "virtual collision" where none visually exists

2. Collision Response Algorithm Limitations

The CharacterController’s collision response works through iterative constraint solving. When approaching an edge:

  1. The controller attempts to move to the target position
  2. Collisions are detected and resolved by “sliding” along surfaces
  3. At edges, multiple collision constraints conflict (floor vs. wall)
  4. The iterative solver can get “confused” and apply minimal movement

3. Ground Detection Raycast Precision Issues

CharacterController maintains internal ground detection through raycasts. At edges:

// Simplified internal ground check logic:
bool isGrounded = Physics.SphereCast(
    position + Vector3.up * 0.1f, 
    radius, 
    Vector3.down, 
    out RaycastHit hit, 
    maxDistance
);

// At an edge, the sphere cast might:
// 1. Miss the edge entirely (falling off unexpectedly)
// 2. Hit both floor and vertical surface (ambiguous result)
// 3. Return inconsistent hit.normal values

4. Floating-Precision Edge Cases

Small edges often represent floating-point precision boundaries:

// Two floor pieces meeting at (0, 0, 10):
Piece A bounds: (-5, 0, 5) to (0, 0, 15)
Piece B bounds: (0, 0, 5) to (5, 0, 15)

// Expected: Seamless floor at x=0
// Reality: Floating point precision creates:
// Piece A: max x = 0.000001
// Piece B: min x = -0.000001
// Result: Microscopic seam that traps CharacterController

Visual Symptoms and Gameplay Impact

Symptom 1: “Snagging” on Door Thresholds

// Character moves toward doorway
void Update() {
    Vector3 move = transform.forward * speed * Time.deltaTime;
    controller.Move(move);  // Smooth movement
    
    // Suddenly stops at doorway threshold
    // Player must "wiggle" or jump to get through
}

Symptom 2: Getting Stuck Between Floor Tiles

// Moving across tiled floor
while (moving) {
    controller.Move(horizontalMove);
    // Intermittently stops at tile boundaries
    // Character appears to "vibrate" stuck in place
}

Symptom 3: Inability to Descend Small Steps

// Approaching a downward step
if (controller.isGrounded) {
    controller.Move(Vector3.forward * speed);
    // Character stops at edge, won't step down
    // Even though step is only 0.1 units high
}

Symptom 4: Edge “Bump” During Smooth Movement

// Platformer-style movement
Vector3 velocity = CalculateVelocity();
controller.Move(velocity * Time.deltaTime);

// Regular "bumps" or hitches when crossing seams
// Movement isn't smooth across level geometry

Common Reproduction Scenarios

Scenario 1: Modular Level Pieces

// Level built from prefab modules
GameObject floorModule = Instantiate(floorPrefab, position, rotation);
// Each module has its own collider
// Seams between modules create perfect edge-sticking conditions

// Character moving from module A to module B
// Gets stuck at the (0.000001 unit) seam

Scenario 2: Sloped Terrain with Micro-Plateaus

// Terrain with overall slope but small flat sections
// Generated terrain or sculpted meshes often have
// micro-variations that are invisible but trap CharacterController

// Moving uphill: Works fine
// Moving across micro-plateau: Gets stuck
// Moving downhill: Falls off plateau edge

Scenario 3: Moving Platforms with Gaps

// Platform with gap for visual detail (e.g., metal grating)
// Visual gap: 0.2 units (looks passable)
// Collider gap: 0.21 units (due to mesh simplification)
// CharacterController radius: 0.5 units
// Skin width: 0.08 units

// Result: Character gets stuck trying to cross gap
// Skin width "bridges" the gap but collision fails

Workarounds and Solutions

1. The Velocity-Based Solution (Most Effective)

Maintain movement momentum and apply continuous velocity rather than discrete moves:

public class SmoothCharacterController : MonoBehaviour {
    private CharacterController controller;
    private Vector3 velocity;
    private Vector3 lastMovement;
    private bool wasStuckLastFrame = false;
    private float stuckTimer = 0f;
    
    void Start() {
        controller = GetComponent();
    }
    
    void Update() {
        // Calculate desired movement
        Vector3 input = new Vector3(
            Input.GetAxis("Horizontal"),
            0,
            Input.GetAxis("Vertical")
        );
        
        // Apply acceleration/deceleration for smoothness
        Vector3 targetVelocity = input * speed;
        velocity = Vector3.MoveTowards(velocity, targetVelocity, acceleration * Time.deltaTime);
        
        // Apply gravity
        if (!controller.isGrounded) {
            velocity.y += Physics.gravity.y * Time.deltaTime;
        } else {
            velocity.y = -0.5f; // Small downward force to maintain ground contact
        }
        
        // Attempt movement
        CollisionFlags flags = controller.Move(velocity * Time.deltaTime);
        lastMovement = velocity * Time.deltaTime;
        
        // Detect if we're stuck
        DetectAndRecoverFromStuckState(flags);
    }
    
    void DetectAndRecoverFromStuckState(CollisionFlags flags) {
        // Check if we're trying to move but not actually moving
        bool tryingToMove = velocity.magnitude > 0.1f;
        bool actuallyMoved = lastMovement.magnitude > 0.01f;
        
        if (tryingToMove && !actuallyMoved) {
            stuckTimer += Time.deltaTime;
            
            if (stuckTimer > 0.1f) { // Stuck for 0.1 seconds
                ApplyStuckRecovery();
            }
        } else {
            stuckTimer = 0f;
        }
    }
    
    void ApplyStuckRecovery() {
        // Solution 1: Small upward boost
        velocity.y = 2f;
        
        // Solution 2: Temporary increase in skin width
        // controller.skinWidth *= 1.5f;
        // StartCoroutine(ResetSkinWidth());
        
        // Solution 3: Apply small "nudge" in movement direction
        Vector3 nudge = velocity.normalized * 0.1f;
        controller.Move(nudge);
        
        Debug.Log("Applied stuck recovery");
        stuckTimer = 0f;
    }
    
    IEnumerator ResetSkinWidth() {
        float original = controller.skinWidth;
        yield return new WaitForSeconds(0.1f);
        controller.skinWidth = original;
    }
}

2. Edge Detection and Response System

Proactively detect edges and adjust movement:

public class EdgeAwareCharacterController : MonoBehaviour {
    private CharacterController controller;
    private float originalSkinWidth;
    private float originalStepOffset;
    
    void Start() {
        controller = GetComponent();
        originalSkinWidth = controller.skinWidth;
        originalStepOffset = controller.stepOffset;
    }
    
    void Update() {
        Vector3 move = CalculateMovement();
        
        // Before moving, check for edges in our path
        if (WillHitEdge(move)) {
            AdjustMovementForEdge(ref move);
        }
        
        controller.Move(move);
    }
    
    bool WillHitEdge(Vector3 intendedMove) {
        if (intendedMove.magnitude < 0.001f) return false;
        
        // Cast rays ahead at different heights
        float[] checkHeights = { 0.1f, 0.3f, 0.5f, 1.0f };
        Vector3 direction = intendedMove.normalized;
        float distance = intendedMove.magnitude + controller.radius;
        
        foreach (float height in checkHeights) {
            Vector3 origin = transform.position + Vector3.up * height;
            
            if (Physics.Raycast(origin, direction, distance)) {
                // Something ahead - check if it's an edge
                if (IsEdgeAtPosition(origin + direction * distance, height)) {
                    return true;
                }
            }
        }
        
        return false;
    }
    
    bool IsEdgeAtPosition(Vector3 position, float checkHeight) {
        // Check if there's ground below this position
        float groundCheckDistance = checkHeight + 0.5f;
        bool hasGroundBelow = Physics.Raycast(
            position, 
            Vector3.down, 
            out RaycastHit hit, 
            groundCheckDistance
        );
        
        if (!hasGroundBelow) {
            return true; // Cliff edge
        }
        
        // Check ground normal - steep normals indicate edges
        float angle = Vector3.Angle(hit.normal, Vector3.up);
        return angle > controller.slopeLimit * 0.8f;
    }
    
    void AdjustMovementForEdge(ref Vector3 move) {
        // For downward edges: ensure we step down
        if (IsDownwardEdgeAhead()) {
            // Temporarily increase step offset or apply downward force
            controller.stepOffset = originalStepOffset * 1.5f;
            move.y -= 0.5f; // Small downward component
            
            // Reset next frame
            StartCoroutine(ResetStepOffset());
        }
        // For upward edges: apply small upward boost
        else if (IsUpwardEdgeAhead()) {
            move.y += 0.3f;
        }
        // For lateral edges: reduce skin width temporarily
        else {
            controller.skinWidth = originalSkinWidth * 0.5f;
            StartCoroutine(ResetSkinWidth());
        }
    }
    
    IEnumerator ResetStepOffset() {
        yield return new WaitForSeconds(0.2f);
        controller.stepOffset = originalStepOffset;
    }
}

3. Collider Geometry Smoothing

Process level geometry to eliminate micro-edges:

public class EdgeSmoothingProcessor : MonoBehaviour {
    [MenuItem("Tools/Smooth Character Colliders")]
    static void SmoothColliders() {
        foreach (GameObject go in Selection.gameObjects) {
            MeshCollider[] colliders = go.GetComponentsInChildren();
            
            foreach (MeshCollider collider in colliders) {
                if (collider.sharedMesh != null) {
                    SmoothMeshEdges(collider.sharedMesh);
                }
            }
        }
    }
    
    static void SmoothMeshEdges(Mesh mesh) {
        Vector3[] vertices = mesh.vertices;
        Vector3[] normals = mesh.normals;
        
        // Identify and merge vertices that are very close
        float mergeDistance = 0.001f; // Merge vertices within 0.001 units
        Dictionary vertexRemap = new Dictionary();
        
        for (int i = 0; i < vertices.Length; i++) {
            for (int j = i + 1; j < vertices.Length; j++) {
                if (Vector3.Distance(vertices[i], vertices[j]) < mergeDistance) {
                    vertexRemap[j] = i; // Map vertex j to vertex i
                }
            }
        }
        
        // Apply remapping to triangles
        int[] triangles = mesh.triangles;
        for (int i = 0; i < triangles.Length; i++) {
            if (vertexRemap.ContainsKey(triangles[i])) {
                triangles[i] = vertexRemap[triangles[i]];
            }
        }
        
        mesh.triangles = triangles;
        mesh.RecalculateNormals();
        mesh.RecalculateBounds();
        
        Debug.Log($"Smoothed mesh: merged {vertexRemap.Count} vertices");
    }
}

// Alternative: Use capsule colliders instead of mesh colliders
public class CharacterWalkableSurface : MonoBehaviour {
    void Start() {
        // Replace complex mesh collider with simple primitive colliders
        MeshCollider meshCollider = GetComponent();
        if (meshCollider != null) {
            Bounds bounds = meshCollider.bounds;
            
            // Create a grid of box or capsule colliders
            CreateSimpleColliderGrid(bounds);
            
            // Remove the problematic mesh collider
            Destroy(meshCollider);
        }
    }
    
    void CreateSimpleColliderGrid(Bounds bounds) {
        float cellSize = 1.0f; // Adjust based on level scale
        
        for (float x = bounds.min.x; x < bounds.max.x; x += cellSize) {
            for (float z = bounds.min.z; z < bounds.max.z; z += cellSize) {
                GameObject colliderObj = new GameObject("WalkableCell");
                colliderObj.transform.parent = transform;
                colliderObj.transform.position = new Vector3(
                    x + cellSize / 2,
                    bounds.center.y,
                    z + cellSize / 2
                );
                
                CapsuleCollider capsule = colliderObj.AddComponent();
                capsule.height = bounds.size.y;
                capsule.radius = cellSize / 2;
                capsule.direction = 1; // Y-axis
            }
        }
    }
}

4. Hybrid CharacterController-Rigidbody Approach

Use Rigidbody for overall movement but CharacterController for collision:

public class HybridCharacterMovement : MonoBehaviour {
    private Rigidbody rb;
    private CharacterController controller;
    private CapsuleCollider capsuleCollider;
    private bool usePhysicsMovement = false;
    
    void Start() {
        rb = GetComponent();
        controller = GetComponent();
        capsuleCollider = GetComponent();
        
        // Configure for hybrid movement
        rb.isKinematic = false;
        rb.constraints = RigidbodyConstraints.FreezeRotation;
        rb.interpolation = RigidbodyInterpolation.Interpolate;
        rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
    }
    
    void FixedUpdate() {
        Vector3 moveInput = GetMoveInput();
        
        // Use physics for general movement
        Vector3 targetVelocity = moveInput * speed;
        Vector3 velocityChange = targetVelocity - rb.velocity;
        velocityChange.y = 0; // Don't affect vertical velocity
        
        rb.AddForce(velocityChange, ForceMode.VelocityChange);
        
        // Use CharacterController for edge case resolution
        ResolveEdgeCasesWithCharacterController();
    }
    
    void ResolveEdgeCasesWithCharacterController() {
        // Temporarily enable CharacterController to check for edges
        controller.enabled = true;
        
        // Store current position
        Vector3 position = transform.position;
        
        // Try a small test move with CharacterController
        Vector3 testMove = rb.velocity * Time.fixedDeltaTime;
        CollisionFlags flags = controller.Move(testMove);
        
        // Check if CharacterController got stuck
        if ((flags & CollisionFlags.Sides) != 0) {
            // CharacterController detected edge collision
            // Apply corrective force
            Vector3 pushDirection = -testMove.normalized;
            rb.AddForce(pushDirection * 5f, ForceMode.Impulse);
        }
        
        // Restore position and disable controller
        transform.position = position;
        controller.enabled = false;
    }
    
    void OnControllerColliderHit(ControllerColliderHit hit) {
        // This will be called when CharacterController is enabled
        if (hit.moveDirection.y < -0.3f && hit.normal.y > 0.5f) {
            // We're going downward and hit upward-facing surface (edge)
            // Apply downward force to step down
            rb.AddForce(Vector3.down * 2f, ForceMode.Impulse);
        }
    }
}

5. Predictive Edge Avoidance

Anticipate edges and adjust movement before getting stuck:

public class PredictiveEdgeHandler : MonoBehaviour {
    private CharacterController controller;
    private Vector3[] previousPositions = new Vector3[10];
    private int positionIndex = 0;
    
    void Start() {
        controller = GetComponent();
        
        // Initialize position history
        for (int i = 0; i < previousPositions.Length; i++) {
            previousPositions[i] = transform.position;
        }
    }
    
    void Update() {
        // Record position history
        previousPositions[positionIndex] = transform.position;
        positionIndex = (positionIndex + 1) % previousPositions.Length;
        
        // Calculate intended movement
        Vector3 move = CalculateMovement();
        
        // Predict if we'll hit an edge
        if (PredictEdgeCollision(move)) {
            // Adjust movement preemptively
            move = AdjustMovementToAvoidEdge(move);
        }
        
        controller.Move(move);
    }
    
    bool PredictEdgeCollision(Vector3 move) {
        // Use position history to detect slowing patterns
        float averageSpeed = CalculateAverageSpeed();
        float currentSpeed = move.magnitude / Time.deltaTime;
        
        // If we're slowing down significantly without input change,
        // we might be approaching an edge
        if (currentSpeed < averageSpeed * 0.3f && averageSpeed > 0.1f) {
            return true;
        }
        
        // Raycast ahead to detect edges
        Vector3 rayOrigin = transform.position + Vector3.up * 0.1f;
        Vector3 rayDirection = move.normalized;
        float rayDistance = controller.radius + move.magnitude;
        
        if (Physics.Raycast(rayOrigin, rayDirection, out RaycastHit hit, rayDistance)) {
            // Check if hit point is an edge
            return IsPositionAnEdge(hit.point);
        }
        
        return false;
    }
    
    bool IsPositionAnEdge(Vector3 position) {
        // Check ground continuity at position
        float checkDistance = 0.5f;
        
        // Cast downward from position and slightly ahead
        Vector3[] checkPoints = {
            position,
            position + transform.forward * 0.1f,
            position - transform.forward * 0.1f,
            position + transform.right * 0.1f,
            position - transform.right * 0.1f
        };
        
        float[] groundHeights = new float[checkPoints.Length];
        bool[] hasGround = new bool[checkPoints.Length];
        
        for (int i = 0; i < checkPoints.Length; i++) {
            hasGround[i] = Physics.Raycast(
                checkPoints[i] + Vector3.up * 0.5f,
                Vector3.down,
                out RaycastHit hit,
                1.0f
            );
            
            if (hasGround[i]) {
                groundHeights[i] = hit.point.y;
            }
        }
        
        // Check for discontinuities in ground height
        float maxHeightDifference = 0f;
        for (int i = 0; i < checkPoints.Length; i++) {
            for (int j = i + 1; j < checkPoints.Length; j++) {
                if (hasGround[i] && hasGround[j]) {
                    float diff = Mathf.Abs(groundHeights[i] - groundHeights[j]);
                    maxHeightDifference = Mathf.Max(maxHeightDifference, diff);
                }
            }
        }
        
        // If ground height varies significantly, it's an edge
        return maxHeightDifference > controller.stepOffset * 0.5f;
    }
    
    Vector3 AdjustMovementToAvoidEdge(Vector3 originalMove) {
        // Try different movement adjustments
        Vector3[] adjustments = {
            originalMove + Vector3.up * 0.1f,      // Small lift
            originalMove * 1.1f,                   // Slightly faster
            originalMove.normalized * 0.5f,        // Slower
            Vector3.ProjectOnPlane(originalMove, Vector3.up) // Remove vertical
        };
        
        // Test each adjustment
        foreach (Vector3 adjustment in adjustments) {
            if (!PredictEdgeCollision(adjustment)) {
                return adjustment;
            }
        }
        
        // If all adjustments fail, apply default recovery
        return originalMove + Vector3.up * 0.2f;
    }
    
    float CalculateAverageSpeed() {
        float totalDistance = 0f;
        int validPoints = 0;
        
        for (int i = 0; i < previousPositions.Length - 1; i++) {
            int nextIndex = (i + 1) % previousPositions.Length;
            float distance = Vector3.Distance(previousPositions[i], previousPositions[nextIndex]);
            
            if (distance > 0.001f) { // Ignore negligible movement
                totalDistance += distance;
                validPoints++;
            }
        }
        
        if (validPoints == 0) return 0f;
        
        float averageDistance = totalDistance / validPoints;
        return averageDistance / Time.deltaTime;
    }
}

Prevention Strategies

1. Optimal CharacterController Configuration

public class OptimalCharacterControllerSetup : MonoBehaviour {
    void Start() {
        CharacterController controller = GetComponent();
        
        // Recommended settings to minimize edge sticking:
        controller.skinWidth = 0.001f;           // Minimal skin width
        controller.stepOffset = 0.35f;           // Standard step height
        controller.slopeLimit = 45f;             // Standard slope
        controller.minMoveDistance = 0f;         // Allow tiny movements
        controller.radius = 0.5f;                // Appropriate for character
        controller.height = 2.0f;                // Standard character height
        
        // Additional optimization:
        controller.center = new Vector3(0, 1, 0); // Center at half height
    }
}

// Editor tool to apply optimal settings
#if UNITY_EDITOR
[InitializeOnLoad]
public class CharacterControllerConfigurator {
    static CharacterControllerConfigurator() {
        EditorApplication.playModeStateChanged += OnPlayModeChanged;
    }
    
    static void OnPlayModeChanged(PlayModeStateChange state) {
        if (state == PlayModeStateChange.EnteredPlayMode) {
            CharacterController[] controllers = GameObject.FindObjectsOfType();
            foreach (var controller in controllers) {
                if (controller.skinWidth > 0.005f) {
                    Debug.LogWarning($"CharacterController on {controller.gameObject.name} has large skin width ({controller.skinWidth}). This may cause edge sticking.", controller.gameObject);
                }
            }
        }
    }
}
#endif

2. Level Design Guidelines

  • Avoid microscopic seams: Ensure level pieces overlap by 0.01-0.05 units
  • Use rounded edges: Apply small fillets to sharp corners
  • Maintain consistent floor height: Variance should exceed stepOffset or be zero
  • Test with CharacterController: Always playtest movement with the actual character prefab

3. Automated Edge Detection in Level Design

#if UNITY_EDITOR
public class EdgeDetectionTool : EditorWindow {
    [MenuItem("Tools/Detect Problematic Edges")]
    static void DetectEdges() {
        float edgeHeightThreshold = 0.05f; // Edges taller than this cause issues
        Collider[] allColliders = FindObjectsOfType();
        
        foreach (Collider collider in allColliders) {
            if (collider is MeshCollider meshCollider) {
                DetectMeshEdges(meshCollider, edgeHeightThreshold);
            } else if (collider is BoxCollider boxCollider) {
                DetectBoxEdges(boxCollider, edgeHeightThreshold);
            }
        }
    }
    
    static void DetectMeshEdges(MeshCollider meshCollider, float threshold) {
        Mesh mesh = meshCollider.sharedMesh;
        if (mesh == null) return;
        
        Vector3[] vertices = mesh.vertices;
        int[] triangles = mesh.triangles;
        
        // Find edges (triangle sides not shared with another triangle)
        Dictionary edgeCounts = new Dictionary();
        
        for (int i = 0; i < triangles.Length; i += 3) {
            for (int j = 0; j < 3; j++) {
                int v1 = triangles[i + j];
                int v2 = triangles[i + (j + 1) % 3];
                Edge edge = new Edge(Mathf.Min(v1, v2), Mathf.Max(v1, v2));
                
                if (!edgeCounts.ContainsKey(edge)) {
                    edgeCounts[edge] = 0;
                }
                edgeCounts[edge]++;
            }
        }
        
        // Edges with count 1 are mesh boundaries (potential problem edges)
        foreach (var kvp in edgeCounts) {
            if (kvp.Value == 1) {
                Vector3 v1 = vertices[kvp.Key.v1];
                Vector3 v2 = vertices[kvp.Key.v2];
                
                // Check if this is a horizontal edge (potential step)
                float heightDiff = Mathf.Abs(v1.y - v2.y);
                if (heightDiff < threshold && heightDiff > 0.001f) {
                    Debug.LogWarning($"Problematic edge found on {meshCollider.gameObject.name}: height difference {heightDiff}", meshCollider.gameObject);
                }
            }
        }
    }
    
    class Edge {
        public int v1, v2;
        public Edge(int v1, int v2) { this.v1 = v1; this.v2 = v2; }
        public override bool Equals(object obj) {
            Edge other = obj as Edge;
            return other != null && v1 == other.v1 && v2 == other.v2;
        }
        public override int GetHashCode() { return v1 * 10000 + v2; }
    }
}
#endif

When to Use Which Solution

ScenarioRecommended SolutionComplexity
Simple levels, occasional stickingVelocity-Based Solution with recoveryLow
Complex geometry, predictable edgesEdge Detection and ResponseMedium
Procedural or generated levelsCollider Geometry SmoothingHigh
Physics-interactive charactersHybrid Controller-RigidbodyHigh
Precision platformer requirementsPredictive Edge AvoidanceMedium-High

Performance Considerations

  • Raycasts for edge detection add CPU overhead (3-5 raycasts per frame is usually fine)
  • Position history arrays have minimal memory impact
  • Mesh processing should be done at design time, not runtime
  • Hybrid physics systems double the collision processing cost
  • Continuous collision checks are more expensive but prevent getting stuck

Recommendation: Start with optimal CharacterController settings and the velocity-based solution. Only add more complex systems if edge-sticking persists in your specific level geometry.

Testing Methodology

[TestFixture]
public class CharacterControllerEdgeTests {
    [UnityTest]
    public IEnumerator Test_Character_DoesNotStuck_OnSmallEdge() {
        // Create test scene with problematic edge
        GameObject floor = GameObject.CreatePrimitive(PrimitiveType.Cube);
        floor.transform.localScale = new Vector3(10, 0.5f, 10);
        
        GameObject edge = GameObject.CreatePrimitive(PrimitiveType.Cube);
        edge.transform.localScale = new Vector3(10, 0.55f, 0.1f);
        edge.transform.position = new Vector3(0, 0.525f, 5);
        
        // Create test character
        GameObject character = CreateTestCharacter();
        
        // Move character toward edge
        CharacterController controller = character.GetComponent();
        Vector3 startPos = character.transform.position;
        
        // Attempt to cross edge
        for (int i = 0; i < 100; i++) {
            controller.Move(Vector3.forward * 0.1f);
            yield return null;
            
            // Check if stuck
            float distanceMoved = Vector3.Distance(
                character.transform.position,
                startPos
            );
            
            Assert.Greater(distanceMoved, i * 0.05f, 
                $"Character stuck at frame {i}, position {character.transform.position}");
        }
        
        // Cleanup
        GameObject.Destroy(floor);
        GameObject.Destroy(edge);
        GameObject.Destroy(character);
    }
}

Conclusion

The CharacterController edge-sticking bug emerges from the tension between the controller's need for reliable collision detection (via skin width and iterative solving) and the player's expectation of smooth, uninterrupted movement across level geometry. The bug is particularly pernicious because it's often level-dependent, appearing only with specific geometry configurations that create the perfect storm of floating-point precision issues, collision constraint conflicts, and movement algorithm edge cases.

The most effective approach combines:

  1. Optimal CharacterController configuration with minimal skin width
  2. Robust movement code that maintains momentum and detects sticking
  3. Level design practices that avoid microscopic seams and height variations
  4. Targeted recovery systems for when sticking inevitably occurs

For most projects, implementing the velocity-based solution with sticking detection and recovery will resolve 90% of edge-sticking issues. For games with particularly complex geometry or precision movement requirements, the predictive edge avoidance or hybrid physics approaches provide more comprehensive solutions.

Remember that character movement is one of the most fundamental player interactions in any game. Smooth, reliable movement across all level geometry isn't just a technical requirement—it's essential for player immersion and enjoyment. By addressing the edge-sticking bug comprehensively, you ensure that players experience your levels as continuous, navigable spaces rather than obstacle courses of invisible traps.

Leave a Reply

Your email address will not be published. Required fields are marked *


Skip to toolbar