Quick answer: Apply continuous downward velocity (gravity * dt or a small −2 unit/sec stick force) on every frame, including when grounded. Without it, the character drifts up imperceptibly and isGrounded oscillates. Add coyote time buffer for jump forgiveness.
Here is how to fix Unity CharacterController.isGrounded flickering on perfectly flat ground. The flag depends on the previous Move call hitting downward; without continuous downward intent, it can flip false for one frame at a time.
The Symptom
Player stands on flat ground. Debug log of isGrounded shows true,false,true,false rapidly. Jump fails sometimes because isGrounded read false on the input frame.
What Causes This
No continuous downward. If you only apply gravity when not grounded, isGrounded flickers as the controller adjusts to floor contact.
Move skipped frames. If you skip Move calls (e.g., during dialog), isGrounded becomes stale.
Slope or microedge. Tiny height differences in the floor can momentarily put the character into a non-grounded state.
The Fix
Step 1: Apply continuous downward velocity.
using UnityEngine;
[RequireComponent(typeof(CharacterController))]
public class PlayerMove : MonoBehaviour
{
private CharacterController cc;
private Vector3 vel;
[SerializeField] private float stick = -2f;
[SerializeField] private float gravity = -9.81f;
void Awake() { cc = GetComponent<CharacterController>(); }
void Update()
{
if (cc.isGrounded && vel.y < 0)
vel.y = stick; // keep pushing down to maintain contact
else
vel.y += gravity * Time.deltaTime;
cc.Move(vel * Time.deltaTime);
}
}
The small constant downward stick force keeps the controller pressed to the floor.
Step 2: Add coyote time for jumping.
private float coyote = 0.12f;
private float lastGrounded;
void Update()
{
if (cc.isGrounded) lastGrounded = Time.time;
if (Input.GetButtonDown("Jump") && Time.time - lastGrounded < coyote)
{
vel.y = jumpVelocity;
}
}
Step 3: Supplement with raycast for visual ground state.
bool raycastGrounded = Physics.SphereCast(
transform.position + Vector3.up * 0.5f,
cc.radius * 0.9f,
Vector3.down,
out RaycastHit hit,
0.6f);
Independent check for animation state.
Step 4: Always call Move every frame. Even at zero horizontal input, call cc.Move(Vector3.zero) or with the stick velocity. Skipping Move makes isGrounded stale.
Step 5: Verify by logging only on transitions.
private bool wasGrounded;
if (cc.isGrounded != wasGrounded)
{
Debug.Log($"Grounded changed: {cc.isGrounded}");
wasGrounded = cc.isGrounded;
}
If transitions still happen rapidly on flat ground, your stick force is too weak.
“Continuous downward stick. Coyote time. Move every frame. isGrounded behaves.”
Related Issues
For CharacterController stair stepping, see Stairs. For Rigidbody jitter, see Rigidbody Jitter.
Stick velocity. Coyote buffer. Move per frame. Grounded steady.