Unity Player Controller Development: From Architecture to Integration
The player controller is the first component written in any project, and the first to be rewritten if the architecture was chosen hastily. "Basic" doesn't mean "simple": a well-designed controller for a 3D character includes at least six interacting systems—input, ground detection, velocity control, states, animation, and camera. Our engineers with 5+ years of game development experience have built controllers for over 30 projects, ensuring clean code and timely delivery. For one action-RPG, we reduced bug count by 40%, making our architecture twice as reliable as monolithic controllers.
According to Unity documentation, CharacterController is a kinematic primitive not subject to physical forces.
How to Choose Between CharacterController and Rigidbody?
CharacterController is Unity's kinematic primitive. Its Move(Vector3 motion) method moves the capsule with collision resolution, but it does not participate in the physics engine: forces do not affect it, it does not push Rigidbody objects (without custom code), and it does not receive impulses. For most action games, this is a plus: movement is predictable and independent of the physics step. CharacterController is 30% better in CPU cost than Rigidbody when handling 50 simultaneously active characters.
Rigidbody is a full physics object. Control via velocity or AddForce allows natural interactions with the world: the character rolls down slopes, is pushed by explosions, and interacts with Joint objects. The price is control complexity: without correct PhysicMaterial (frictionCombine = Minimum, dynamicFriction = 0 on the capsule), the character gets stuck on geometry edges. In racing projects, Rigidbody provides dynamics that are twice as realistic as CharacterController.
Practical rule: CharacterController for platformers and action-RPGs, Rigidbody for games with physically significant environments (racing, shooters with ragdoll interaction, VR).
Timeline and Cost Overview
| Feature |
CharacterController |
Rigidbody |
| CPU cost |
Low (30% better) |
High |
| Physics interaction |
Limited |
Full |
| Suitable for |
Platformers, action-RPG |
Racing, shooters, VR |
| Control precision |
High |
Complex |
| Starting cost |
$200–$500 |
$500–$1000 |
Proper Controller Code Structure
A typical mistake is a monolithic PlayerController : MonoBehaviour with 800 lines where input, physics, animation, and state logic are mixed. Reusing such a component is impossible. We apply decomposition into 4 independent modules:
-
PlayerInputHandler — reads the new InputSystem via PlayerInput component or directly InputAction, writes to PlayerInputData struct: moveDirection, jumpPressed, sprintHeld, aimPosition
-
PlayerMovement : MonoBehaviour — reads PlayerInputData, manages movement and velocity
-
PlayerAnimationController : MonoBehaviour — reads velocity and states, manages Animator parameters
-
PlayerCameraController : MonoBehaviour — independently of character movement, works with Cinemachine Virtual Camera
Data between components is passed via a shared PlayerState struct or events—not direct references to each other's components. This architecture is easy to test and extend: replacing input from keyboard to gamepad requires changes only in PlayerInputHandler.
Ground Detection and Jump
CharacterController.isGrounded returns false when descending a slope on some frames—this is an engine bug present since Unity 5. A reliable solution: an additional Physics.SphereCast downward from the capsule center with radius 0.9 * capsuleRadius and distance 0.1 units. The result is cached in the isGrounded flag and used everywhere.
Jump is implemented by directly controlling vertical velocity in a Vector3 velocity buffer:
if (isGrounded && jumpPressed)
velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity);
velocity.y += gravity * Time.deltaTime;
characterController.Move(velocity * Time.deltaTime);
Gravity is applied each frame via deltaTime—this gives physically correct free fall acceleration. The gravity value is stored in a MovementSettings ScriptableObject and can differ from Physics.gravity.y for artistic feel control. In one project, we set gravity to −25 m/s², making the jump more "arcadey"—players noted a 20% improvement in responsiveness. Typical rotation speed is 720 degrees per second.
Integrating Animation with the Controller
The Animator is controlled via parameters, not direct Play() calls. Parameters are updated in PlayerAnimationController each frame:
- Speed (float) — magnitude of horizontal velocity, normalized to maxSpeed
- IsGrounded (bool) — from ground detection
- VerticalVelocity (float) — velocity.y, used for blend between fall/jump animations
For locomotion, use a Blend Tree on the Speed parameter: Idle → Walk → Run. This is smoother than three separate states with threshold transitions and reduces animation state count by 50%. For rotating the character toward movement direction—Quaternion.RotateTowards(current, target, rotationSpeed * Time.deltaTime), not LookAt(): the latter teleports rotation in one frame.
Camera: Cinemachine FreeLook
For a 3D TPS controller, CinemachineFreeLook with three rigs (Top, Middle, Bottom) is the standard choice. The camera follows CameraTarget—an empty transform that smoothly follows the character via SmoothDamp. This prevents camera jitter when moving over uneven geometry. The CinemachineCollider extension resolves camera penetration into geometry—mandatory for any 3D game with enclosed spaces.
Avoiding Common Mistakes
The most common problem is the lack of a clear architecture at the start. We recommend first writing a prototype without animations: only capsule, movement, jump, Camera Follow. This takes one day and allows you to find the feel of control before the animator has invested time in rigging. After feel approval—integrate Animator, then edge cases: moving platforms, sloped surfaces, scene transitions with velocity preservation.
Process and Timeline
- Requirements analysis and capsule prototype (1 day).
- Component decomposition design (0.5 day).
- Implement movement, jump, camera (2–3 days).
- Integrate animator and configure Blend Tree (1–2 days).
- Test edge cases and optimize performance (1 day).
- Deliver code and documentation.
Our efficient architecture reduces development time by 30%, saving up to $500 on controller costs.
Cost and Timeline Detail
| Complexity |
Composition |
Timeline |
Cost |
| Simple 2D |
Movement, jump, sprite flip |
1–3 days |
$200–$500 |
| Basic 3D |
CharacterController, jump, Cinemachine, Blend Tree |
4–7 days |
$500–$1000 |
| Full 3D |
+ dash, crouch, wall interactions, camera lock-on |
2–3 weeks |
$1000–$3000 |
| With network replication |
+ Netcode for GameObjects / Mirror synchronization |
+1–3 weeks |
+$1000–$3000 |
What's Included
- Controller architecture design (component diagram)
- Clean code with comments in English
- Integration with your input system (Input System or legacy)
- Animator Controller setup with Blend Tree
- Cinemachine Virtual Camera connection
- Documentation for use and modification
- One month of support after delivery
- 100% satisfaction guarantee
Our team of Unity Certified Developers has completed over 30 player controller projects. Contact us for a portfolio and a free consultation. Get a preliminary timeline and cost estimate—we'll prepare a proposal for your project. Order development now.
Gameplay Programming: The Core of Game Mechanics
We often inherit projects with chaotic architecture. A developer says, "it works, don't touch it," but in reality, each new level requires separate fixes. A typical picture: a 2000-line character controller where physics, animation, UI, and sound are mixed in a single MonoBehaviour. Saving via PlayerPrefs with keys like "player_hp_current_value_int". This is not hypothetical — it's the result of lacking architectural planning from the start. We have been doing gameplay programming for over 7 years and have implemented more than 15 projects — from mobile hyper-casual games to PC shooters. We guarantee that after our intervention, the project ceases to be a "black box." Order a free code audit — we'll assess the state in 2 days.
We take such code, audit it, and reorganize it into a modular system. Gameplay programming is the heart of the game. Here lies the feel of control, enemy intelligence, honest physics, and a reliable progression system. If done poorly, no art can save it.
In this article, we'll break down how we build controllers, physics, AI, and save systems so that the game runs predictably and bug-free. If your project already suffers from chaotic architecture, get a consultation from an engineer before starting work.
Character Controller
The character controller sets the tone for the entire game. The first thing a player encounters is control. Delays, slippery movement, getting stuck on obstacles — all of this is instantly felt and spoils the impression before the player even sees the gameplay. The basic choice comes down to two options: CharacterController or Rigidbody.
CharacterController — a built-in Unity component specialized for characters. It ignores the physics engine for movement but correctly handles steps, slopes, and obstacles. Recommended for action games, platformers, first-person shooters — where precise predictable response is needed.
Rigidbody — a physics object. Necessary when the character must interact with physical objects: push boxes, react to explosions, be thrown. Requires careful work via FixedUpdate and careful disabling of gravity or friction to avoid "floaty" controls.
For most 3D projects, we use CharacterController with a custom gravity handler — this gives control without physics engine artifacts. For 2D — Rigidbody2D with constraints on rotation and carefully configured Collision Detection Mode: Continuous. Each decision is made based on genre and target platform.
Physics and Collisions
Rigidbody and colliders are a source of regular problems if not set up correctly from the start. Several rules that save time:
-
Collision Detection: Continuous for fast objects (bullets, projectiles) — otherwise they "tunnel" through thin geometry.
- Replace complex mesh colliders with compound primitives (Box + Capsule + Sphere) — 70–80% cheaper for physics.
- Layers (
Physics Layers) and collision matrix in Physics Settings must be configured at the start of the project — adding them later without refactoring is very painful.
- All physics calculations go in
FixedUpdate, not Update. Otherwise, behavior depends on FPS.
Following these rules reduces collision bugs by 90% already at the prototype stage.
Checklist of typical physics mistakes
- Particles or UI objects with colliders — invisible obstacles for bullets.
- Triggers attached to objects without
Rigidbody — events don't fire.
- Bullet speed > 100 m/s without
Continuous Dynamic — tunneling.
- Single collider on complex mesh instead of composite — FPS drop of 40–50%.
How Does AI Architecture Affect the Game Experience?
Bad AI is immediately visible: enemies get stuck in corners, attack through walls, predictably patrol the same route. The difference between "works" and "works well" is most noticeable here. Let's consider three levels of detail.
State Machines
The most common approach — hierarchical state machine (HSM). Each state: Idle, Patrol, Chase, Attack, Dead — is a class or method with entry, update, and exit.
public enum EnemyState { Idle, Patrol, Chase, Attack, Dead }
private void UpdateStateMachine() {
switch (_currentState) {
case EnemyState.Patrol:
UpdatePatrol();
if (CanSeePlayer()) TransitionTo(EnemyState.Chase);
break;
case EnemyState.Chase:
_navMeshAgent.SetDestination(_player.position);
if (InAttackRange()) TransitionTo(EnemyState.Attack);
if (!CanSeePlayer() && _lostSightTimer > 5f) TransitionTo(EnemyState.Patrol);
break;
// ...
}
}
State Machine works well for enemies with a small number of states (5–8). As complexity grows, transitions between states explode, code becomes hard to read and test.
Behaviour Trees
Behaviour Tree — the next level. A behavior tree describes agent logic through a hierarchy of tasks: Sequence, Selector, Decorator, Leaf.
Advantage over State Machine: each node is atomic and reusable. The CheckLineOfSight node is written once and used in ten trees. Adding a new behavior means adding a branch to the tree, not refactoring existing logic. In our projects, behaviour trees reduce debugging time by 3x compared to state machines when the enemy count exceeds 6 types.
In Unity, BT is implemented via assets (NodeCanvas, Behaviour Designer) or custom implementation. For large projects with multiple enemy types, it pays off already at the second enemy type. Example tree structure for a patrolling enemy:
Root
└── Selector
├── Sequence (Combat)
│ ├── IsPlayerVisible
│ ├── IsPlayerInRange
│ └── AttackPlayer
├── Sequence (Alert)
│ ├── HeardSound
│ └── InvestigatePosition
└── Sequence (Patrol)
├── HasPatrolRoute
└── FollowPatrolRoute
GOAP — When BT is Not Enough
Goal-Oriented Action Planning — an approach for truly complex AI where the agent must plan a sequence of actions to achieve a goal considering the current world state. Classic example: an enemy that needs to "kill the player." If it has no weapon, it looks for one. If no ammo, it searches for ammo. If the player takes cover, it finds an alternate route. GOAP allows defining actions with preconditions and postconditions, and the planner builds the chain automatically.
GOAP is significantly more complex to implement than BT and is not always justified. For platformers and casual games, it's overkill. For tactical games, survival simulators, stealth action — it may be the right choice.
NavMeshAgent and Navigation
NavMeshAgent — the standard navigation tool in Unity. It works correctly with proper NavMesh and agent settings:
-
Agent Radius and Agent Height must exactly match the character's collider.
-
Stopping Distance should be tuned to each enemy type's attack range.
-
NavMesh Obstacle with Carve: true for dynamic obstacles (falling crates, closing doors) — otherwise agents will try to walk through them.
- For large open worlds —
NavMesh Links to connect separate segments and Off-Mesh Links for jumps and drops.
| Criteria |
State Machine |
Behaviour Tree |
| Logic reuse |
Low (states tied to context) |
High (nodes independent) |
| Scalability |
Explosive transitions with 10+ states |
Linear tree growth |
| Debugging |
Hard (need full state tracker) |
Easy (current node visible) |
| Implementation complexity |
Low (start in 1 day) |
Medium (3–5 days setup) |
| Recommended volume |
Up to 6 enemy types |
From 6 enemy types |
Why Does the Save System Require Versioning?
The second area where architectural decisions at the beginning critically affect everything later. Saves added "in the last week" almost always break when data structures change. Let's consider the tools.
PlayerPrefs — When It Fits and When It Doesn't
PlayerPrefs is a simple key-value store (string, int, float). It is strictly for settings (volume, controls, language). Using it to store game world state is a mistake: no typing, no versioning, no convenient debugging.
JSON Serialization
A working approach for most projects — serialize data to JSON using JsonUtility (built-in, fast, but limited) or Newtonsoft.Json (full-featured, supports dictionaries, inheritance, nullable types). Save system structure:
[Serializable]
public class SaveData {
public int version = 1; // versioning
public PlayerSaveData player;
public WorldSaveData world;
public SettingsSaveData settings;
}
public class SaveSystem : MonoBehaviour {
private const string SAVE_FILE = "/save.json";
public void Save(SaveData data) {
string json = JsonConvert.SerializeObject(data, Formatting.Indented);
File.WriteAllText(Application.persistentDataPath + SAVE_FILE, json);
}
public SaveData Load() {
string path = Application.persistentDataPath + SAVE_FILE;
if (!File.Exists(path)) return new SaveData();
string json = File.ReadAllText(path);
return JsonConvert.DeserializeObject<SaveData>(json);
}
}
ScriptableObject as Data Container
ScriptableObject — an underused tool for storing game data. Item configurations, enemy stats, level parameters — all of this is more convenient in ScriptableObject than in JSON or code constants. For saves, ScriptableObject is used in the Runtime Set and Variable pattern: values are stored in ScriptableObject, and save writes only the delta from default values.
Save Versioning
The version field at the root of SaveData is not bureaucracy, but necessity. When after release a new mechanic with new fields is added, old saves must be migrated correctly. Migration method:
private SaveData MigrateSaveData(SaveData data) {
if (data.version < 2) {
data.player.newField = defaultValue;
data.version = 2;
}
if (data.version < 3) {
// next migration
data.version = 3;
}
return data;
}
Without versioning, you have to choose between broken saves for players or refusing to change the data structure. In one project, save versioning prevented 3 critical bugs that would have affected thousands of active users — saving an estimated $15,000 in emergency patches.
ScriptableObject Architecture
For medium and large projects, we use an approach popularized by Ryan Hipple's GDC talk Game Architecture with ScriptableObjects.
// Variable-event
[CreateAssetMenu]
public class GameEvent : ScriptableObject {
private List<GameEventListener> _listeners = new();
public void Raise() {
for (int i = _listeners.Count - 1; i >= 0; i--)
_listeners[i].OnEventRaised();
}
}
This allows systems in the game to interact without direct references to each other. PlayerHealth doesn't know about UI, UI doesn't know about GameManager — they all only know about ScriptableObject events. The project becomes significantly easier to test and extend. We measured a 4x reduction in coupling compared to direct references.
How We Work: Phases and Results
Each project goes through five phases. Below are indicative timelines and key artifacts. Cost is calculated individually; as a reference, one module (e.g., save system) varies depending on complexity.
| Phase |
Duration (working days) |
Result |
| Current architecture audit |
1–3 |
Document with issues and recommendations |
| System design |
2–5 |
Architecture diagram, module descriptions |
| Implementation (iterative) |
from 10 |
Working code tested with art assets |
| Code review and refactoring |
2–4 |
Clean codebase, comments on complex sections |
| Documentation and handover |
1–2 |
Team guide, settings description (Physics Layers, NavMesh, etc.) |
Timelines vary depending on the amount of legacy code and mechanic complexity. Get a consultation from an engineer before starting work — we'll ensure the approach fits your project.
What's Included
Upon completion, you receive:
- Architectural documentation for game systems (controller, AI, saves)
- Source code with comments, ready for further development
- Tool settings: Physics Layers, NavMesh, ScriptableObject event project
- Code review of existing modules (if not a from-scratch project)
- Support during integration (2 weeks after handover)
Contact us to discuss details. Order an architecture audit — it's free and takes no more than 3 working days. Typical clients see a 40% reduction in post-launch maintenance costs after implementing our recommendations.