Tips for writing more flexible game code


We’ve looked at some tips for writing cleaner code, now let’s take a slightly higher level view of our application and discuss how we can keep our code flexible and more receptive to change. This is not intended as an authoritative or exhaustive list by any means, but rather a collection of small, actionable tips that you can use to immediately improve your codebase.

Respect the application hierarchy

Let’s first discuss the foundational idea behind several of the tips we’ll discuss today: Every application has a hierarchy, and this hierarchy needs to be respected. This means that dependencies should only flow down the hierarchy, never up or laterally. Additionally, we generally want to go down only one level for any dependencies. This keeps our code properly decoupled and makes it easy to use various objects around the application wherever we need them since each object is able to work in isolation and has no dependencies on the state or the structure of the application beyond its realm of focus.

As an example of this principle, let’s consider the simplified hierarchy of a small game:

Game
|-- Level
    |-- Geometry
    |-- Items
        |-- Coin 1
            |-- Sprite
            |-- Physics body
        |-- Coin 2
            |-- Sprite
            |-- Physics body
        |-- Level exit
            |-- Physics body detector
    |-- Player
        |-- Animation container
            |-- Various animations
        |-- Physics body

If our Player object is completely isolated and only concerned with what it contains, internally handling everything it needs to function properly, then we can put our Player wherever we want: other levels, bonus game modes where the game structure is completely different, or even as an interactive way of selecting levels from a menu. But if the Player always assumes that it will have a sibling in the tree named Items, that immediately breaks this flexibility, as we now have to have an Items object at the same level of the Player in all locations, whether or not we need it.

Additionally, if we start crawling around the tree, we also have the potential to lock ourselves into the implementation of our sibling and parent dependencies. How much flexibility does our game have if we always assume that there is an Items sibling with Coin objects as direct children, each with a function called play_sfx()? This has nothing to do with the Player specifically, but if we change or break this dependency, our Player code is now broken as well.

So that establishes why we don’t want to assume structure above or next to our object in the tree, but why is going too far down a problem? For the same reason: assumption of structure and separation of concerns. If our Player needs to change animations, it can tell the animation container to do so, but it shouldn’t try and manipulate individual frames of a specific animation, as that is beyond its scope. This separation means we can change our implementation later without breaking the player code. Maybe we originally used individual images to stitch into an animation and later switch to a spritesheet for animation. The Player, which is only concerned with the overall, high-level operation of our character, really doesn’t need to know or care how the animation is made, only that it has an object it can talk to for requesting animation changes.

Keep data and behavior together

This one is largely based around the rule “Tell, Don’t Ask” from The Pragmatic Programmer, with a bit thrown in from Martin Fowler’s discussion on this rule. Generally speaking, we want to keep data and the logic operating on those data together, rather than ask an object for its data so we can operate on them separately.

So, revisiting the example above about the Player’s relationship to the animation container, the Player should tell the animation container to change animations, not manually access the data within the animation container and make any required changes, as that moves the logic of changing animations outside of the animation-oriented code and into an object that really shouldn’t concern itself how the transition occurs.

Bad - Separating data and logic

# player.gd
var animation_list = animation_container.get_animation_list()
var new_animation = animation_list.find_animation('walk')
animation_container.current_animation = new_animation

Good - Data and logic stay together

animation_container.play_animation('walk')

Creating a function on the animation container that the Player calls keeps our animation-related code where it belongs and ensures we can reason about the state of the application at any time, as there are no external effects to be worried about. For example, if an animation isn’t playing properly, we know that the broken code must be contained within the animation container, not sitting in a random function elsewhere in our application. This also keeps our code reusable and flexible since all the functionality we may need is contained to our animation object. Enemies, NPCs, and the Player can all use one consistent interface to manage animations. This rule may seem a bit obvious a lot of the time, but it can easily sneak its way into your application when you really just need to make one small change this one time.

Use components

The previous rules really lead right into this one. Rather than creating large monolithic objects to get all the functionality you need, or going overboard with inheritance, use components to compartmentalize smaller, self-sufficient bits of code and compose objects out of the components (component in this case does not necessarily refer to an entity component system, but rather just the concept of creating objects that are responsible for specialized areas of knowledge). For instance, there’s probably a lot of objects in your game that have a health bar: the player, enemies, breakable crates, and so on. Rather than having to manage separate variables and methods on each object to manage health, you could create a health component, or even an entire stats component, that houses and standardizes the data and functions you need. Now, everything that needs a health bar can use this component, making it a lot easier to manage, bugfix, and expand over time.

Components can massively improve the organization and reusability of your code, and are a technique that a lot of people could do well to go to more often. Sure, there’s some more overhead to creating the components and wiring up communication if multiple components need to be aware of each other, but even just taking the easy wins, like adding a status effects component or a health component, can go a long ways in helping you clean up your codebase.

Health bars are a great use of components. Why write separate variables and functions for each individual object when you can create a health or stats component and place it everywhere you need it?

Use dependency injection

Dependency injection is about supplying dependencies at runtime, rather than compile time, and is a great technique to make your code reusable and flexible. Sure, it’s easy to say that there’s a hierarchy to follow and that certain design patterns can help you maintain that hierarchy, but there are times when you do still need a reference to something outside of your direct descendants. State machines come to mind, for instance, since they’re generally owned by an object higher in the hierarchy than them, but they need a reference to that object so that they can control it. If we hardcode this reference, we’ll have to duplicate our state machine for every object, which is a pain in the butt and breaks the principle of keeping our code DRY.

With dependency injection, we can instead say our state will control an object of type X, such as a RigidBody, and write our code around that type, but without any specific references to the objects that will hold this state. At runtime, a parent object can pass a reference of itself to the state and it will now control that specific object.

// By setting the state owner in our object initialization,
// this state can be reused on multiple objects
class State {
    var owner: Rigidbody;

    function init(stateOwner): void {
        this.owner = stateOwner;
    }
}

That’s dependency injection in a nutshell. Hopefully you can see just how useful it can be in a lot of different situations, including the previously mentioned technique about compartmentalizing functionality into components. With components and dependency injection, we can write generic code to control movement, health, AI, and more that can flexibly operate on different objects as needed.

In Elemechs, callback functions and related dependencies are passed to individual mechs via dependency injection so that the player and enemy mechs can flexibly work in multiple levels. “Combatants” passes data to “enemies” and “players”, and then those nodes pass the dependencies on to the individual mechs.

Learn, and use, design patterns

Look, I get it. Design patterns aren’t the sexiest thing in the world to study, especially if you don’t feel a need for them right away. I put off learning about state machines until I had hit my mental limit of writing if statements and storing booleans to figure out what the hell was going on in my code. But they are important to understand, especially some of the bigger and more universal ones. Why bother reinventing the wheel, and probably doing a worse job of it, when a lot of common issues software developers run into have already been solved.

But I’m not going to leave it at just telling you to go study software architecture (though that’s not the worst idea in the world if you’ve got ambitions for systems-driven games…), so here’s what I’d prioritize:

  1. State machines: I’ve talked about state machines multiple times already, with one theory post and two Godot-oriented posts discussing them, which is way more attention than I’ve given than any other single subject on this site, and I could probably squeeze out at least one more post if I felt compelled to. Let that be a signal of just how strongly I feel you should be familiar with this pattern. Whether you prefer to use enums or object oriented code, learn state machines. Even the simplest of games can usually benefit from them.

  2. The observer pattern: State machines may be number one on this list, but the observer pattern is a very close second since it has huge implications for making your code more flexible. With native support in most engines and programming languages, meaning you don’t even have to put a lot of time or effort into implementing it, this is arguably the biggest bang for your buck when studying design patterns.

  3. The mediator pattern: I’ve not covered this one yet, but it’s coming. I’ll just leave it at this, if the observer pattern is too one-way for you, this is the pattern you want to study.

Once you’re comfortable with these patterns, you can start diving into the specific issues you’re running into, or even just doing a full read of Game Programming Patterns and seeing what sticks out to you.

Take an iterative approach

That’s largely it for today, but I want to leave you one more important tip: it’s ok to be iterative with improving your code style. Don’t try to learn every design pattern right away and spend weeks on your next project making your code as perfect as possible. Too much theory and not enough practice can burn you out or just lead to not really understanding the why and how. Plus, a game with mediocre code that ships is better than a perfectly coded game that never does. It’s ok to work until you hit a pain point and then research how to fix it. It can even be worth doing it “wrong” on a small project, seeing what the final product looked like and what your own lessons learned were, and then looking at researching how to do better in the future. Remember, both game and software development are entire career paths, it takes time to learn how to tackle the problems you run into.