Create your own
Lesson illustration

Configurable MonoBehaviours with Safe Component References

Hello again. Your BattleFire project now has a saved Main scene and a predictable asset layout. We can begin the player code without turning PlayerController.cs into a collection of hard-coded values and fragile assumptions.

In this lesson, you will create the initial PlayerController component. It will expose movement settings in the Inspector, automatically require a Rigidbody, retrieve and cache that component when the object initializes, and fail clearly rather than producing a later NullReferenceException. The next lesson will configure the player Rigidbody fully and use this cached reference for physics-based movement.


A MonoBehaviour is both code and a Unity component

A class that inherits from MonoBehaviour can be attached to a GameObject. Unity then treats that script as a component, alongside components such as Transform, Rigidbody, Collider, and AudioSource.

For BattleFire, PlayerController will live on the Player GameObject. Its responsibilities will grow to include movement and rotation, but its initial job is to establish a clean contract:

  1. Designers or you-in-the-Inspector may tune movement values.
  2. The Player must have a Rigidbody on the same GameObject.
  3. The script obtains that Rigidbody once and retains the reference for later use.

This separation matters. A movement speed is a gameplay setting, so it should be configurable. A Rigidbody is an internal dependency: the script needs it to do work, but nobody should need to drag the Player’s own Rigidbody into a field every time a Player prefab is created.

Public does not mean “Inspector setting”

Unity serializes public fields by default, which is why beginners often write:

public float moveSpeed = 5f;

This works, but public has a second C# meaning: any other script can freely read and change that field. That is often broader access than you intend.

A stronger default is:

[SerializeField] private float moveSpeed = 5f;

The field is:

  • private to protect it from arbitrary access by other scripts;
  • serialized so Unity saves it and displays it in the Inspector;
  • initialized with a sensible default for newly added components.

Read Unity’s short reference on this distinction.

Unity - Scripting API: SerializeField

Read Unity’s SerializeField reference to separate C# access control from Unity’s Inspector serialization.

In the Description section, read the serialization explanation. Then inspect the code example below it: compare the ordinary private age field with the private field that has [SerializeField]. Focus on the idea that Inspector visibility does not require making a field public.

When Unity saves a scene or prefab, it saves the serialized values on that specific component instance. That produces an important practical consequence:

The value in code is the default for a new component; an Inspector value already saved on a scene object or prefab can override that default.

For example, changing moveSpeed = 5f to moveSpeed = 7f in code does not necessarily change an existing Player prefab already saved with speed 5.

Unity’s Inspector shows `AudioClip` fields exposed by a MonoBehaviour script. The same Inspector-to-field relationship will expose BattleFire’s serialized movement settings; this example also shows that component references are a distinct kind of field from numeric tuning values.

Identify the PlayerController dependencies

Before writing code, distinguish the fields by their purpose.

CategoryBattleFire exampleInspector-visible?Why
Tuning valuemoveSpeedYesIt is useful to adjust movement feel without editing code.
Tuning valuerotationSpeedYesIt will control how quickly the player turns later.
Internal dependencyRigidbody rbNoThe script can find the Rigidbody on its own GameObject.
Temporary gameplay statea future movement directionUsually noIt changes during play and is not a design-time setting.

The Rigidbody reference needs special attention. Calling GetComponent<Rigidbody>() searches the same GameObject that owns PlayerController; it does not search the entire scene. If the Rigidbody is on a child object or a different GameObject, this call will not find it.

Unity’s official GetComponent overview illustrates both the same-GameObject case and the different-GameObject case.

C# GetComponent in Unity! - Beginner Scripting Tutorial

Watch Unity’s “C# GetComponent in Unity! - Beginner Scripting Tutorial” for the basic meaning of a component reference and the reason to retrieve it once rather than repeatedly.

Watch same-object access to see why the generic type in GetComponent<Rigidbody>() identifies the component you want. Then watch other-object access to contrast this with retrieving a component from a GameObject reference. Finish with the caching advice: for BattleFire, we will retrieve the Rigidbody during initialization, not every frame.

A reference variable does not create a new Rigidbody. It points to the existing Rigidbody component Unity has attached to the Player. That distinction is fundamental:

private Rigidbody rb;

The line above declares an empty reference variable. At this stage, rb is null.

rb = GetComponent<Rigidbody>();

This later assignment asks Unity for the existing component and stores its reference in rb.


Make the dependency explicit with RequireComponent

A PlayerController without a Rigidbody is a configuration error. Rather than relying on memory, tell Unity that the dependency is mandatory:

[RequireComponent(typeof(Rigidbody))]

Placed directly above the class, this attribute tells Unity to add a Rigidbody when the script is added to a GameObject that lacks one. It converts an informal instruction—“remember to add Rigidbody”—into a constraint enforced by the editor.

Read the Unity documentation before using it.

Unity - Scripting API: RequireComponent

Read Unity’s RequireComponent reference to understand both its protection and its limitation.

In the Description section, read the dependency behavior and note. Then review the PlayerScript code example, where Rigidbody is required and retrieved in Start. For BattleFire, we will use the same relationship but retrieve it in Awake.

There is one subtle limitation: RequireComponent is checked when Unity adds the script component. If a GameObject already had PlayerController before you added the attribute, Unity does not retroactively repair that old object. This is why robust code still checks the result it receives.

Why retrieve in Awake?

Awake is an initialization method Unity calls when the component is loaded or instantiated, before ordinary gameplay begins. It is an appropriate place to establish internal references that later logic requires.

MonoBehaviour Lifecycle Basics - Awake OnEnable Start Update FixedUpdate | Unity C# Basics Part 3

Watch the selected parts of LlamAcademy’s “MonoBehaviour Lifecycle Basics” to place initialization in the Unity execution lifecycle.

First watch the lifecycle overview for the relative roles of Awake, Start, Update, and FixedUpdate. Then skip to Awake setup, where a required component is retrieved and stored before later logic uses it. Apply that same pattern to the Player Rigidbody.

Awake does not mean “put all code here.” It is for setup. In the next lesson, physics motion will belong in FixedUpdate, while the cached Rigidbody remains available to that code.


Implement the initial PlayerController

In the Project window, open Assets/Scripts/Player. Create a C# Script named PlayerController. Unity requires the file name and the MonoBehaviour class name to match exactly.

Replace Unity’s generated script contents with this:

using UnityEngine;

[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour
{
    [Header("Movement Tuning")]
    [SerializeField, Min(0f)] private float moveSpeed = 5f;
    [SerializeField, Min(0f)] private float rotationSpeed = 720f;

    private Rigidbody rb;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();

        if (rb == null)
        {
            Debug.LogError(
                "PlayerController needs a Rigidbody on the same GameObject.",
                this
            );

            enabled = false;
        }
    }
}

Let’s read it from the outside inward.

1. The class-level requirement

[RequireComponent(typeof(Rigidbody))]
public class PlayerController : MonoBehaviour

typeof(Rigidbody) provides the component type to the attribute. When PlayerController is attached normally, Unity ensures that its GameObject has a Rigidbody too.

The public keyword on the class is normal Unity script structure. It does not make all the fields inside the class public.

2. Inspector-facing tuning fields

[Header("Movement Tuning")]
[SerializeField, Min(0f)] private float moveSpeed = 5f;
[SerializeField, Min(0f)] private float rotationSpeed = 720f;

[Header] adds a readable label in the Inspector. It has no gameplay effect.

Both numeric fields use:

  • [SerializeField], which exposes and saves them;
  • private, which keeps direct access inside PlayerController;
  • [Min(0f)], which prevents negative values from being entered through the Inspector.

At this stage, moveSpeed and rotationSpeed do not move anything. They are intentionally prepared before the movement implementation. rotationSpeed is set in degrees per second as a convention that will be used when we add player rotation.

3. The cached internal reference

private Rigidbody rb;

This does not need Inspector exposure because the correct target is unambiguous: the Rigidbody on this Player GameObject. Making it private signals that it is managed by PlayerController, not manually assigned by a scene designer.

4. Retrieval and defensive failure

private void Awake()
{
    rb = GetComponent<Rigidbody>();

    if (rb == null)
    {
        Debug.LogError(
            "PlayerController needs a Rigidbody on the same GameObject.",
            this
        );

        enabled = false;
    }
}

The first line retrieves the Rigidbody once. Caching it avoids repeated component searches during gameplay.

The if check is defensive programming. It protects against unusual setup states, including old objects created before the RequireComponent attribute was present. If retrieval fails:

  • Debug.LogError creates a clear Console message;
  • passing this lets you click the message to identify the faulty Player GameObject;
  • enabled = false disables only this PlayerController component, preventing later code from using a missing reference.

Without this guard, a later line such as rb.linearVelocity = ... would fail with a less useful NullReferenceException.


Attach it and inspect the contract

If your scene already contains the Player GameObject, select it in the Hierarchy. Otherwise, use a temporary Capsule named Player solely to test this script; the next lesson will complete the Player’s physical configuration.

Drag PlayerController.cs from Assets/Scripts/Player onto the Player GameObject, or use Add Component in the Inspector.

When the script is attached, inspect these results:

  1. A Player Controller component appears.
  2. Under Movement Tuning, Move Speed begins at 5 and Rotation Speed begins at 720.
  3. A Rigidbody is present on the same GameObject. Unity may add it as a result of RequireComponent.
  4. The Console has no error from PlayerController.

For now, do not tune Rigidbody gravity, rotation constraints, collision detection, or movement code. Those settings interact, and the next lesson addresses them together for stable top-down physics.

A useful quick check is to change Move Speed to 8, save the scene, close and reopen it, and confirm it remains 8. Then return it to 5 if that is the baseline you want. This verifies that the field is serialized rather than merely initialized in C# each time the project compiles.


A compact decision rule for future fields

When you add fields to BattleFire scripts, use this decision process:

QuestionTypical choice
Should this be adjusted per prefab or scene instance?[SerializeField] private
Does this script require a component on its own GameObject?[RequireComponent] plus retrieval in Awake
Is it a reference the script can find unambiguously on itself?private cached reference via GetComponent
Does another script genuinely need to control it?Expose a focused method or property later, rather than defaulting to a public field
Does it change every frame during play?Keep it private unless another system truly needs it

For example, later PlayerHealth may expose a maximum-health setting in the Inspector, but it should keep its current health internal and change it through a TakeDamage method. That protects the object’s rules instead of allowing any script to write an invalid value directly.


Key takeaways

You now have the foundation of a reliable BattleFire player component:

  • Use [SerializeField] private for values that should be configurable in the Inspector without becoming freely writable by other scripts.
  • Keep internal component references, such as Rigidbody rb, private and cache them once.
  • Use [RequireComponent(typeof(Rigidbody))] to enforce a same-GameObject dependency when the script is added.
  • Retrieve required references in Awake, then guard against a missing reference with a clear error and component disable.
  • Avoid calling GetComponent repeatedly in frame-by-frame logic.

Next, you will configure the Player GameObject’s Rigidbody, collider, tag, and constraints so it is physically stable and ready for top-down movement.

Can't find a good explanation? Sign up and we'll make it for you

Sign up