11 tips for writing cleaner code
The subject of clean code is everywhere in professional software development circles, with an infinite number of books, blogs, and videos to choose from, but it tends to get overlooked among casual game developers. Presumably, this is because of some combination of the following:
- Hobbyists that learn just enough coding to get by
- A lot of people are either working alone or are the only programmer on their team, meaning the cracks in their code don’t show as quickly
- Simply put, it’s not a sexy subject - at least not until you realize your code is unmanageable and you need to fix it somehow
Not that there’s anything wrong with these reasons. Not every hobbyist, in any hobby, needs to spend hours studying their subject from a more academic perspective, and when you have limited time to put towards a creative outlet, you want to spend it actually creating!
But because the subject of clean code is important for anyone doing any amount of coding (not to mention how easily the codebase of a game can become a mess), let’s talk about how to write cleaner, more readable, and more maintainable code.
Write for the reader
This is really the root reason for everything in this post today. Write for the human that will be reading your code in the future, whether that’s you an hour from now, a teammate a week from now, or again, and probably most likely, yourself one year from now when you want to pull some useful code for another project. 99.9% of the time, the computer will take care of itself. It’s the pesky and ephemeral human mind you need to worry about when writing your code. Remember this, and all these other rules will seem obvious.
Use meaningful, descriptive names
This is one of the most fundamental and important principles to hold onto, and with code completion built into any editor worth its salt, and therefore making it so you don’t even have to type out the whole variable or function name more than once (when you first declare it), there’s no excuse for using short or abbreviated names in a modern codebase. Variables should clearly define what data they hold and any units associated with it, while functions should clearly state what they do. You should also generally avoid any abbreviations that aren’t commonly understood and standardized. mph
for miles per hour is ok, for instance, but any custom abbreviations or lingo should be avoided.
This is important so that the intent of your code is easily understandable to anyone new to it, and that even includes yourself if you work alone. Come back to your code in three months to a year and see how well you remember what it’s doing. You’ll be glad you made it clear why each variable and function exists.
Bad - Names are vague
var dpt = 10
func calc(a, b):
...
Good - Names make the purpose of each variable and function clear
var damagePerTurn = 10
func calculateTotalDamage(baseDamage, damageModifiers):
...
There is one exception to this rule, and that is for variables that are more or less globally and conventionally named as such, such as naming the current index of a for loop i
:
for i in range(damageModifiers.size()):
...
No magic numbers
This is related to the last point so there’s not as much to say about it, but don’t use “magic numbers” in your code - those that are arbitrary and unnamed. Any sort of configuration or multiplier should be clearly named so that another developer (or future you!) can make sense of why this number is used.
Bad
for i in range(20):
...
Good
const NUMBER_OF_ITEM_SLOTS = 20
for i in range(NUMBER_OF_ITEM_SLOTS):
...
Prefer smaller functions
Rather than prescribe an ideal function length, as is the case in a certain famous book on coding structure, I’m just going to leave it at this: prefer writing shorter functions, and do so by delegating to more functions as needed. Doing so makes it easier to read and follow the logic of your code, and can help ensure your code is more reusable. A big, monolithic function of 100 lines is hard to make sense of, and is almost certainly doing too much, whereas a nice and short five line function calling other functions is easy to understand and ensures you’re making your code more reusable.
Bad
func determineNextTarget():
var availableTargets = []
for i in range(allTargets.size()):
if allTargets[i].distance < MAX_TARGET_DISTANCE:
availableTargets.append(targets[i])
var mostValuableTarget
for i in range(availableTargets.size()):
if !mostValuableTarget or availableTargets[i].value > mostValuableTarget.value:
mostValuableTarget = availableTargets[i]
var weakestTarget
for i in range(availableTargets.size()):
if !weakestTarget or availableTargets[i].health > mostValuableTarget.health:
weakestTarget = availableTargets[i]
if mostValuableTarget.value * VALUE_WEIGHTING > weakestTarget.health * HEALTH_WEIGHTING:
return mostValuableTarget
return weakestTarget
Good
func determineNextTarget():
var availableTargets = getAvailableTargets(allTargets)
var mostValuableTarget = getMostValuableTarget(availableTargets)
var weakestTarget = getWeakestTarget(availableTargets)
return selectBestTarget(mostValuableTarget, weakestTarget)
Forget comments and write self-documenting code
Comments are a “code smell” (a piece of code that is indicative of a potentially larger issue in the codebase) and therefore should be rare in a well-written codebase. With poorly written code, they’re used as a bandage over unclear code that really just needs to be refactored so its intent is clearer. In an otherwise good codebase, they just take up space and make code comprehension harder by distracting the reader. Additionally, they tend to become quickly outdated as the code evolves but they’re forgotten and left behind.
That’s not to say that all comments are bad, just that they should be rare. Working with an external library or API with some wonkiness to it? Go ahead and put a comment clarifying your workaround. Want to leave a comment clarifying what a function you wrote does? Forget it and instead refactor the code until its intent is naturally clearer.
Let’s use one of our previous code examples, with a twist, to show the good and the bad:
Bad - Comments are a crutch, as code can be written to be more clear
# Damage done per turn
var dpt = 10
# Calculate the total damage
func calc(a, b):
...
Bad - These comments are unhelpful and unnecessary
# Damage done per turn
var damagePerTurn = 10
# Calculate the total damage
func calculateTotalDamage(baseDamage, damageModifiers):
...
Good - Clarifies interaction with code that is beyond our control
var damagePerTurn = 10
# SuperCoolLibrary returns total damage as a percentage of target's maximum health
# so scale by maxHealth to get real damage value
var totalDamage = maxHealth * SuperCoolLibrary.calculateTotalDamage(target, baseDamage, damageModifiers)
Good - Code is naturally clear in its intent
var damagePerTurn = 10
func calculateTotalDamage(baseDamage, damageModifiers):
...
Make use of whitespace
This applies to both the spaces within your code, the code’s indentation, and the line breaks themselves. Smartly place whitespace to make your code more readable by grouping together related bits of code, separating unrelated bits of code, and making non-alphanumeric characters easier to read. It can also be used to show the relation between code over multiple lines, most commonly seen when indenting all parameters of a function when the call takes up more than one line. Like anything, too much whitespace can be a bad thing, so work on finding that middle ground and you’ll be good to go.
Bad - Code is cramped, harder to parse, and unrelated code runs together
var totalDamage=calculateTotalDamage(baseDamage,damageModifiers,baseDefense,defensiveModifiers)
var currentHealth-=totalDamage
updatePlayerHealthUI(currentHealth)
var hasCounterAttack=rollForCounterAttack()
if hasCounterAttack: processCounterAttack()
Good - Code is grouped logically, and provides some “breathing room” to the code
var totalDamage = calculateTotalDamage(
baseDamage,
damageModifiers,
baseDefense,
defensiveModifiers
)
var currentHealth -= totalDamage
updatePlayerHealthUI(currentHealth)
var hasCounterAttack = rollForCounterAttack()
if hasCounterAttack:
processCounterAttack()
Respect (and use!) line limits
Line limits are a basic way to keep your code readable. Too short of lines make you have to jump lines too often, and therefore mentally readjust yourself (by however small amount it happens). Conversely, lines that are too long can increase mental effort by making you lose your spot compared to the rest of the code base. Plus, they can just feel long when reading them. Even when the code is well written, a two-hundred character line of of code is a lot to take in.
So how long should your lines be? Traditionally, 80 characters was the upper limit (Apparently this is based off of the number of holes in a punch card, but also matches the character limits of early computers), but nowadays most people will bump it up to anywhere from 100 to 150 characters per line. For most code, I personally stick to a limit of 140 characters, though I will go down to 100 when using Godot’s built-in code editor since the window is smaller in the engine than in something like VSCode, and word-wrapping code is just gross.
Bad - Code is too long to comfortably read
var totalDamageAfterModifiers = calculateTotalDamageWithModifiers(enemy.calculateBaseDamage(), enemy.attackModifiers, player.armorClass, player.weaknesses, world.getEnvironmentModifiersForLocation(player.position))
Good - Code is broken up over multiple lines to make it easier to read and follow
var totalDamageAfterModifiers = calculateTotalDamageWithModifiers(
enemy.calculateBaseDamage(),
enemy.attackModifiers,
player.armorClass,
player.weaknesses,
world.getEnvironmentModifiersForLocation(player.position)
)
Use enumerators
Aversion to enumerators is something I see from time to time in the gamedev world for some reason, so let me just say that you should use enumerators when you have a fixed number of options to choose from in your code. They aren’t prone to typos, differences in capitalization, or encoding errors like strings are, and they’re much easier to remember, and more flexible, than numbers. Enumerators make it so you know what you’re selecting when you write your code, you know your selection won’t be misinterpreted (forgetting which number is for which selection, for instance), and if you want to make changes to your available options or order of options later, it doesn’t impact the code you’ve already written. Enumerators. Use them. Love them.
Bad - Using error-prone strings to represent a fixed selection
selectPotion('mana')
# Uh oh
selectPotion('helth')
func selectPotion(name):
if name == 'mana':
...
elif name == 'health':
...
Bad - Using numbers to represent a fixed selection
# No clue if these are correct or what potion they represent
# without looking at the larger codebase
selectPotion(1)
selectPotion(3)
func selectPotion(potion):
if potion == 1:
...
elif potion == 2:
...
Good - Using enumerators for selection
enum POTIONS {
HEALTH,
MANA
}
selectPotion(POTIONS.HEALTH)
selectPotion(POTIONS.MANA)
func selectPotion(name):
if name == POTIONS.MANA:
...
elif name == POTIONS.HEALTH:
...
Keep it DRY
D.R.Y., or “Don’t repeat yourself”, is a very important concept to remember when writing code. If you find yourself doing the same thing more than once or maybe twice, turn that code into a function so that it can be used as many places as possible, even if it’s just a single line of code. This reduces codebase size, let’s you more quickly understand the code (since you can just see a well-named function call and reason about what it does, or even just give it a glance once and then understand every call after), and makes it easier to maintain your code since you only have to edit your code in one location and have the changes reflected everywhere.
Bad - Writing the same logic in multiple places
var finalDamage = attackDamage - player.baseDefense + player.defenseModifiers - enemy.attackModifiers
# Roll for counterattack if damage is completely negated
if (player.baseDefense + player.defenseModifiers - enemy.attackModifiers) > finalDamage:
rollForCounter(player.baseDefense + player.defenseModifiers - enemy.attackModifiers - finalDamage)
Good - Reusing code via a function
var finalDamage = attackDamage - getTotalDefense()
# Roll for counterattack if damage is completely negated
if getTotalDefense() > finalDamage:
rollForCounter(getTotalDefense() - finalDamage)
func getTotalDefense():
return player.baseDefense + player.defenseModifiers - enemy.attackModifiers
Functions should do one thing
This is really just a sliver of a larger concept regarding code structure and architecture, but I want to go ahead and mention it here since it’s an easy trap that even experienced programmers fall into. If you feel the need to use the word “and” to describe what a function does or if you can’t reuse a function because it has other side effects, you’re probably trying to do too much with it. Ideally, a function should do one thing and one thing only. If it’s doing more than one thing, you probably need to break out its functionality into more functions and then wrap those calls into a higher order function that properly establishes the intent.
Bad - This function does too much
# Can't be reused if you just want to update a local save
func saveProgressToDiskAndSyncToCloud():
...
Good - Multiple functions make this code more reusable
# Individual functions can be reused or called together where needed
func saveProgress():
saveProgressToDisk()
syncSaveToCloud()
func saveProgressToDisk():
...
func syncSaveToCloud():
...
Use your language’s features and conventions
And lastly, let’s go really broad. Don’t reinvent the wheel or fall prey to Not invented here syndrome. If your language provides a built-in way to do something or has a conventional way of doing something, go with it. You don’t want to waste your time creating problems for existing solutions.
For example, GDScript has all sorts of built-in functions to help you speed up your development process, and since these come standard in Godot, their functionality is widely tested and used across a huge number of projects and developers. Additionally, pretty much every class and data type in the engine has built-in functions to save you time and effort. Use these functions and save your efforts for more unique problems than normalizing a vector or rounding a number.
Bad - Reinventing the wheel
# As an example, stepNumber is converted directly from the Godot source code for stepify
# https://github.com/godotengine/godot-cpp/blob/82bc10258191d4efe64be6239ae86eed70b49e5a/include/godot_cpp/core/math.hpp#L420-L425
var steppedNumber = stepNumber(101, 10)
func stepNumber(pValue: float, pStep: float) -> float:
if pStep != 0:
pValue = floor(pValue / pStep + 0.5) * pStep
return pValue
Good - Making use of Godot’s built-in functions to save time, effort, and code size
var steppedNumber = stepify(101, 10)
Conclusion
And we are finally at the end! Hopefully these tips will help you write cleaner, more maintainable, and more readable code. I have intentionally excluded architectural tips and discussions since that’s a discussion worth having entirely on its own, so stay tuned for more info on that in the future.