Programming Tips #1 - If Statements
Programming Tips #2 - Making Progress
Programming Tips #3 - Design
Programming Tips #4 - Datums
Programming Tips #5 - Organization
When you first start programming, the problem you're trying to solve is "how do I write code that'll do ______?". Initially you're happy just to get something working, who cares what the code looks like. But when you try to develop a complete game this indifference can become a problem. As your code gets messier and messier, the project becomes harder to work on - you're more likely to write code that has bugs and they become harder to track down and fix. Eventually it'll get to the point where you're wasting a lot of time, don't feel productive, and are more likely to stop working on the project.
As a more experienced programmer, instead of asking "how do I do ______?", you should start asking "what's the best way to do ______?". In this post we'll look at different ways to write code to see how implementing a feature different ways can make game development easier.
Most games are just collections of simple features. Think about any BYOND game, even a complex one. Every feature in that game can either be implemented easily or it can be decomposed into a set of features which can all be implemented easily. Something that you think is complex (ex: a combat system) is really just a collection of simple features. A more complex combat system is just a larger collection of features.
If every individual feature is easy to write, why is game development hard? There are two reasons:
1. Games have lots of features. After decomposing features into basic, simple parts instead of saying "have a menu for the player to select attacks", you'd have things like "pressing the up arrow moves the cursor up in the combat menu." If you look at features on that level there are hundreds of them.
2. Features are related to each other. How you implement one proc complicates how you implement another one. You have to be aware of how things work, what procs are called, and what their return values are. As you add more features, there's more to remember, and this becomes difficult to handle.
In this article we're going to look at how you can structure code in different ways to make game development easier on yourself.
BYOND has a lot of built-in features and procs that make it easy to implement things. The problem is that BYOND's built-in features rarely do exactly what you need. There might be a built-in proc that is a 90% fit of what you need to do, but it's not a 100% fit. If every time you use a built-in proc you have to add a little code to make it work a certain way, every proc in your program will be a little more complex than it needs to be. This complexity will add up.
Instead of using BYOND's built-in features to implement your game, first use BYOND's features to implement the features your game will need, then use those features to make the game.
For example, you can use the oview() proc to find what mobs are near an enemy. You can use this in the enemy's AI routine to make it select a target. The problem is that oview() doesn't do exactly what we need - it'll return the list of all mobs near the enemy but not every mob is a valid target. We don't want the enemy to attack its friends or dead mobs. The oview() proc helps us implement this but it doesn't do everything we need. Here are two ways to implement this:
// first method:
mob
enemy
proc
ai()
var/mob/target
for(var/mob/m in oview(5, src))
// ignore dead and friendly mobs when looking for a target
if(m.dead) continue
if(m.team == team) continue
// if we have a target, only select m as the new target if its
// health is less than the current target's health
if(target)
if(m.health < target.health)
target = m
else
target = m
if(target)
attack(target)
// second method:
mob
enemy
proc
get_targets(r = 5)
var/list/targets = list()
for(var/mob/m in oview(r, src))
// ignore dead and friendly mobs when looking for a target
if(m.dead) continue
if(m.team == team) continue
targets += m
return targets
// override the enemy's get_target() proc to make it select the weakest mob
get_target(r = 5)
var/mob/target
for(var/mob/m in get_targets(r))
// if we have a target, only select m as the new target if its
// health is less than the current target's health
if(target)
if(m.health < target.health)
target = m
else
target = m
return target
ai()
var/mob/target = get_target(5)
if(target)
attack(target)
Both methods do the same thing using almost the same exact code. The first method does it all in one proc - it loops through nearby mobs, filters out non-targets, and selects the weakest one. The second method uses the get_target() and get_targets() procs as helpers. get_targets() returns a list of possible targets, get_target() picks the weakest one. Doing things the second way gives us a few benefits:
1. The code looks complex but it's better organized. Each proc has one clear task and its own name. If you do too much work in a single proc you'll have to remember what that proc does. If the larger function is split into smaller tasks and each task is put in its own proc, each proc gets a name that can accurately describe what it does.
2. Each function is in a separate proc so we can override individual parts. If you wanted to make the AI select the strongest enemy, in the first method you have to rewrite the whole thing. With the second method you'd just have to override the get_target() proc.
3. The ai() proc is way simpler. In a real project the ai() proc will likely grow to become the most complex of them all. Once it reaches a certain size it'll be hard to work with. If it takes you 11 lines of code to make super-basic AI you're limiting how much you can expand on it before it becomes too difficult to modify. In the second example the ai() proc is only three lines - you can easily add to it without it getting complex.
We're not doing this because the oview() proc is bad. We're just using it to create a proc that does exactly what we need and is easy to use. This keeps your code simple and easy to work with. As I said in the first "programming tips" article: " Good programmers don't effortlessly make sense of complex code, they know how to write simple-looking code to do the same thing." If you're making a complex game your code is going to be complex, it doesn't need your help. It's important to find ways to keep it simple from the start. If you don't, eventually the code will become very difficult to work with, progress will slow down, and you'll lose interest - this is probably why most BYOND games are never finished.
Here's another example, consider a basic "who" command:
// first method:
mob
verb
who()
for(var/mob/m in world)
if(m.client)
src << m
// you could also do it this way:
for(var/client/c)
src << c.mob
// second method:
var
list/players = list()
mob
Login()
..()
players += src
Logout()
..()
players -= src
verb
who()
for(var/mob/m in players)
src << m
The first method shows how BYOND lets you use for loops to easily find all of the players in the game. Most people use that type of approach because the second method requires a list that keeps track of players, but creating that list is hardly any extra work. Having a list of players simplifies the who command and many other things you might like to add:
1. What if there are AI players who should be listed too? The second method makes this easier because you just have to add them to the players list when they're created.
2. What if a single game server can support multiple game instances and you want to list players in your game instance, not all players on the server?
3. What if some live players are just observers and you don't want them to be listed, or you want them to be shown separately?
4. What if you want to have the game pick a player at random to be the leader who selects the game mode?
These are all features you may need to have in your game. By relying on the features that BYOND provides these things are a little more difficult to implement. By first implementing the feature you need (maintaining a list of players) these features are all very simple to add.
Lots of BYOND games lack polish because they skimp on these details. People focus on the basic functionality (ex: a combat system) but don't add many other details. The game ends up being more like a demo of the combat system than an actual game. By finding ways to make these features easier to add, you'll be able to add polish to your game more easily.
BYOND gives you some useful procs but using them directly can really complicate your code. By keeping your code simple and organized it'll be easier to work with and you'll be able to accomplish more. One thing that often makes a mess of your code is the interface.
BYOND doesn't do you any favors here. Screen objects and interface controls are great, but they're terrible to work with. It can take a lot of screen objects to create a decent HUD and updating one interface control can take many ugly calls using winset(), winget(), params2list(), list2params(), text2num(), and other procs.
Let's consider a basic game function and how it handles updating the interface:
mob
proc
attack(mob/target)
target.health -= 10
This attack() proc works fine, but what happens when we want to make it update the target's interface? It would seem strange to make my attack() proc update your interface directly. It'll also have problems if there are multiple attacks - in each place we modify your health we also need to update your interfance. We'll change it to this:
mob
proc
attack(mob/target)
target.damage(10)
damage(d)
set_health(health - d)
set_health(h)
health = h
Now we have a single proc, set_health(), that manages changes to your health var. From this proc we can handle updates to the interface:
mob
proc
set_health(h)
health = h
health_meter.icon_state = "health=[h]"
The health_meter object is a screen object we use to display an indication of your health. This code will work, but this still might not be the best way to handle it.
The set_health() proc is grouped with your attack and damage code because that's what it's related to. The problem is that it also updates the interface and it's not grouped with your interface code. Think about every feature that is represented by the interface - health, mana, money, skills, experience, etc. If the code to manage how money is displayed by the interface is with money-related code and the code to manage how the interface displays experience is with the experience/level up code, to update your interface you'll have to make changes all over the place. Instead, you can create procs to manage the interface and put them all in the same place:
Interface
var
mob/mob
New(mob/m)
mob = m
proc
set_health(h)
mob.health_meter.icon_state = "health=[h]"
mob
var
Interface/interface
client
New()
..()
mob.interface = new(mob)
// in other code files:
mob
proc
set_health(h)
health = h
if(interface)
interface.set_health(h)
This gives us two benefits:
1. All of the interface-related code is in the same place. If you want to change the game to use interface controls instead of screen objects you just have to make changes in one place.
2. The code to update the interface is separated from the code that triggers the update. You can make the interface more complex (ex: make the health value displayed using many screen objects instead of just one) and the mob's set_health() proc stays the same, it's just the interface code that changes. The code that calls the interface object's procs stays simple, even if you use tons of complex calls to winset() and winget().
Sometimes BYOND's features do exactly what you need, but usually that's not the case. Usually they're a close fit but not a perfect fit. Instead of using these slightly-misfitting features to create your game, use BYOND's features to implement the features you need, then use the features you implemented to create your game. By implementing the exact feature you need you simplify the code that uses the feature.
Doing this doesn't always take more work. Look at the first code example in the article - the second method has almost exactly the same code as the first example, it's just split up into separate procs. You're not doing extra work, you're just changing how you think about it and how things are organized. You can even see this in the last example. The two lines of code (setting health = h and updating the screen object's icon_state) are present in both cases, it's just a difference in organization.