Code Style

Preface:

I wrote this up for a job application (don't worry, going to keep working on Exoloper) but I figured it might actually work as a nice little showcase of how I code. It's worth keeping in mind that I'm 100% self taught here, so there's likely some very odd choices being made.

UnitMover.cs

UnitMover.cs Here's the file, feel free to download it

So the main file of focus is UnitMover.cs. The purpose of this file is to be the one stop shop for moving for any kind of unit within the game. All units' movement is controlled via a standard Unity NavmeshAgent, though their inputs will vary based on unit type and whether it's controlled by the player or an AI.

I live and breathe inside VSCode whenever I'm working on Exoloper, and I have a bunch of VSCode Snippets to generate my classes. They pre-namespace and layout the basic class depending on its purpose.

//  Created by Matt Purchase.
//  Copyright (c) 2023 Matt Purchase. All rights reserved.
using System.Collections.Generic;
using System.Collections;
using UnityEngine;
using UnityEngine.AI;

namespace Anchorite.Exoloper.Game {

    public class UnitMover : EX_Component {

Every one of my classes come with a set of comments that seperate out the class. These headings are: Dependencies, Properties, Initialisation Functions, Unity Callbacks, Public Functions, andPrivate Functions. It's just an easy way for me to keep track of my files and quickly skip to the right sections.

All my variables are named with camel-case, hungarian notation. I personally prefer this as it easily allows me to seperate out function variables from class variables (and also allows me to quickly seperate out my variables from Unity's)

One thing to note is that UnitMover is subclassed from EX_Component (file provided). All EX_Component does is house virtual events for Initialise(), Disable(), and OnEventRecieve(n_eventType type, EntityEventArgs ea). Every active gameobject in the core gameplay scene will be made of an EX_Entity and at least one EX_Component and the core gameplay is achieved by entities passing events to components. I explicitly chose not to use Unity's similar implementation here as I wanted something far more structured, particularly, limiting events to only those that are available within the scope of the enum n_eventType which helps significantly for both cpu speed (no string parsing), refactoring and scoping.

		// Dependencies

        // Properties
        [SerializeField] private NavMeshAgent m_agent;
        [SerializeField] private Transform m_aim;

        // TODO: maybe this be subclassed?
        public bool m_tankMovement = false;
        public bool m_tankRotating = false;

        public float m_currentSpeed { get; private set; }
        public float m_maxSpeedReverse { get; private set; }
        public float m_maxSpeedForward { get; private set; }
        public float m_desiredSpeed { get; private set; }
        public float m_targetSpeed { get; private set; }
        public float m_acceleration { get; private set; }
        public float m_rotationSpeed { get; private set; }


Almost every gameobject facing class I write has some kind of initialisation method that isn't explicitly tied to Unity's callbacks and UnitMover is no different. Here we're just initialising the local variables based on the UnitData struct.

   // Initalisation Functions
        private void OnUnitInitialised(UnitData data) {
            m_maxSpeedForward = ((UnitController)m_parent).m_campaignData.m_speed;
            m_maxSpeedReverse = -(((UnitController)m_parent).m_campaignData.m_speed / Config.Instance.m_defaultReverseSpeedDivisor);
            m_acceleration = data.m_moveAccel;
            m_rotationSpeed = data.m_rotationSpeed;
            m_agent.enabled = true;
            m_agent.angularSpeed = data.m_rotationSpeed;
        }

UnitMover is a pretty core piece of code in Exoloper, so unfortunately it's OnEventRecieve is a bit of a doozy. It should all be pretty self explanatory. Each event type comes with it's own EventArgs struct, though not every use case of the event requires that. For brevity's sake I've only included a couple lines of this switch statement, feel free to see the full statement in the source code.

public override bool OnEventRecieve(n_eventType type, EntityEventArgs ea) {
            switch (type) {
                case (n_eventType.unitInitialised_UnitInitialisedEventArgs): {
                        UnitInitialisedEventArgs init = ea as UnitInitialisedEventArgs;
                        OnUnitInitialised(init.data);
                        break;
                    }
                case (n_eventType.mover_setToPlayer): {
                        OnPlayerSet();
                        break;
                    }
                case (n_eventType.mover_setToAI): {
                        OnAISet();
                        break;
                    }
                case (n_eventType.mover_setDestination_Vector3EventArgs): {
                        Vector3EventArgs args = ea as Vector3EventArgs;
                        OnDestinationSet(args.pos);
                        break;
                    }
                case (n_eventType.mover_teleport_Vector3EventArgs): {
                        Vector3EventArgs args = ea as Vector3EventArgs;
                        OnTeleportRequested(args.pos);
                        break;
                    }
            }
            return base.OnEventRecieve(type, ea);
        }

I try to keep my code that executes within Unity's callbacks as thin as possible. It might look like I'm doing something stupid with broadcasting player speed every frame, but another pattern I follow is that each function should (generally) have it's own conditions.

        // Unity Callbacks
        private void OnEnable() {
            m_agent.enabled = false;
        }

        private void Update() {
            if (m_agent == null) { return; }
            BroadcastPlayerSpeed();

            // TODO: Subclass this or something?
            TankStopWhenInRange();
            TankRotateOnSpot();
        }

Another pattern that I've tried to follow as much as possible with Exoloper is to keep the publicly available content of a class to a minimum, instead letting the classes interface via a set of events, making it easier to maintain each class in isolation. These functions aren't anything particularly special, and that's kind of the point.

While I'm on the topic of patterns, a major style choice I live by is writing my code as though it needs to be read by a junior programmer. I avoid ternaries, limit inlining and shorthand wherever possible, and generally try to do the simplest implementation of a function I can. This is for two reasons, 1) it's easier to come back to code that's dead simple to read, and 2) if someone else has to pick it up, even without comments, they should be able to understand it quickly without having to fight a bunch of inlining or lambda functions. this does mean that I end up writing more lines of code, but it's not like screens are small or storage space is limited.

        // Public Functions

        // TODO: Add reset functions?

        public void ModifyBonusSpeed(float mod) {
            m_maxSpeedForward += mod;
        }

        public void ModifyAcceleration(float mod) {
            m_acceleration += mod;
        }

After this point most of the rest of the class is just a ton of private functions that get called from the OnEventRecieved() function. I'll highlight a couple that I think are interesting.

One of the odd function calls inside the update loop. I'm in the process of deciding if this should be inside the AI code for tank enemies or not so it's flagged as such. I like using TODO's particularly with a highlighter extension in vscode that also indexes the locations and count of TODO's, so that I can come back to them later.

The intention of this function is to slow a tanks throttle down when within a certain distance of it's target. I don't want to call stop on the agent as that behaviour looks unrealistic in game (instant stop).

You can see a rare instance of inlining an if statement. Many of my functions may have multiple bailouts based on certain conditions, in this case, if the mover doesn't use tank movement we don't want to execute this function. Here you'll also see a reference to Config which is a scriptable object that contains all my lovely magic variables. This is setup to be easily tweaked for a designer (aka, me later on.) I try to push as many variables out to the editor as possible, but sometimes can be lazy, especially when the number is inconsequential. (see the -1000 number).

        // TODO: Move to AI?
        private void TankStopWhenInRange() {
            if (m_tankMovement == false) { return; }
            if (Vector3.Distance(transform.position, m_agent.destination) < Config.Instance.m_aiStoppingDistanceNavigate) {
                OnThrottleRequested(-1000);
            }
        }

This function highlights the use of the Messenger system that I've been using in my newer projects. Previously I used standard C# delegates and events, but that required having a direct reference to an object, and I wanted a more global system. So using this allows me to Broadcast events off into the void, and for other classes to subscribe to those events. Again, I like using enums instead of strings for event types for refactor and speed purposes, so I changed the Messenger source to listen to enums instead. It makes it slightly less portable, but it saves me so much time in refactoring.

Here I'm using a slightly unconventional naming system for the enum n_eventTypes where I split it into sections

   private void BroadcastPlayerSpeed() {
            if (m_parent != CombatController.Instance.m_player) { return; }
            Messenger.Broadcast<float>(n_eventTypes.combat_playerSpeed_FLOAT, m_agent.velocity.magnitude);
        }

Generally this is as complicated as the majority of my functions get. The purpose of this function is to respond to events like MouseAim or Controller Aim and rotate the m_aimcomponent of a unit by a Vector3. The m_parent variable refers to the EX_Entity of the current unit. The general thinking here is to modify the rotation of the m_aim object and then to correct it back within bounds based on Config variables. I'm using config here instead of a unit variable as I don't plan on separating that out on a per unit basis, though switching it will be trivial if I choose to.

If I wanted to optimise this I'd probably cache the Vector3that I assign to localEulerAngles.

        private void OnTorsoRotateRequested(Vector3 rot) {
            m_aim.localEulerAngles += new Vector3(rot.x * m_rotationSpeed * Config.Instance.m_pitchRotationFactor, rot.y * m_rotationSpeed, 0);

            float pitch = m_aim.localEulerAngles.x;
            float yaw = m_aim.localEulerAngles.y;

            if (pitch > Config.Instance.m_maximumPitch && pitch < 180) {
                pitch = Config.Instance.m_maximumPitch;
            }

            if (pitch < 360 + Config.Instance.m_minimumPitch && pitch > 180) {
                pitch = 360 + Config.Instance.m_minimumPitch;
            }

            if (m_parent != null) {
                m_parent.SendEvent(n_eventType.unit_torsoRotated_FloatEventArgs, new FloatEventArgs(yaw, gameObject));
            }

            Messenger.Broadcast<float>(n_eventTypes.combat_unit_torsoRotated_FLOAT, yaw);

            m_aim.localEulerAngles = new Vector3(pitch, yaw, 0);
        }


I love coroutines, and well, here's one (and it's accompanying executor function).

The core experience of this game is piloting big heavy robots that whack each other with heavy munitions. Getting knocked around by heavy ordinance really helps to sell this, and provides the player with an incentive to not get hit, as it also applies a slowdown, making further shot more likely to connect (not quite a stunlock, but not far from it.)

I'm not amazing at maths, so I'll frequently turn to StackOverflow, Google, Unity Documentation or other sources that are written by humans (I don't trust LLM's in the slightest) to point me in the right direction. Fortunately for me, you can google nearly anything for action games. Every time I do this I'll leave a comment for the code I've copy-pasted, so I know what I'm looking at, and so that I can rewrite it in a format that matches my styling.

The Coroutine here is pretty boring, nothing super special, and upon review, I can see I should be caching that WaitForFixedUpdate somewhere, but it's a pretty good indicator of my general approach to them.

   private void OnProjectileKnockback(ProjectileDamageEventArgs dam) {
            if (dam.m_knockBackPower <= 0) { return; }

            // Gets a vector that points from the player's position to the target's.
            // var heading = target.position - player.position;
            // var distance = heading.magnitude;
            // var direction = heading / distance; // This is now the normalized direction.

            UnitController parent = (UnitController)m_parent;

            Vector3 heading = dam.context.transform.position - parent.m_raycastQueryPoint.position;
            float dist = heading.magnitude;
            Vector3 direction = heading / dist;

            StartCoroutine(DoKnockBack(dam.m_knockBackPower, direction));

            for (int a = 0; a < Config.Instance.m_defaultKnockBackTime; a++) {
                OnSlowDownRequested();
            }
        }

        private IEnumerator DoKnockBack(float power, Vector3 dir) {
            for (float a = 0; a < power; a++) {
                Vector3 moveDir = dir * Time.deltaTime * power;
                // TODO: add these to debug visibility bools
                Debug.DrawLine(m_agent.transform.position, m_agent.transform.position + moveDir, Color.magenta, 10.0f);
                Debug.DrawRay(m_agent.transform.position + moveDir, Vector3.up, Color.white, 10.0f);
                m_agent.Move(moveDir);
                yield return new WaitForFixedUpdate();
            }
        }

I hope this gives you a general idea of my approach to programming, and that I like to keep it simple, legible, and refactorable.