ID:37940
 

Dream Tutor: Bulletproof Code

by Lummox JR

One of the most important concepts in programming is robustness. Your program shouldn't break when most unexpected situations crop up; it should fail gracefully. It doesn't take a lot of planning or work to make your code robust; all it takes is a little thought.

True or False?

Have you ever seen code like this?

if(happy == 1) Smile()
if(happy == 0) Frown()

That's wrong on many levels. First, it's doing two if() checks where only an else is needed for the second line. But worse, it's comparing happy both times to a specific number. This is what the correct code looks like:

if(happy) Smile()
else Frown()

Values in DM can be considered true or false. BYOND knows happy is true if it's not any of the following:

  • 0
  • An empty string
  • null

So what if happy was accidentally replaced with an object? Then you have a type mismatch runtime error when you compare it to the number 1. And what if it becomes a different number, not 1 or 0? That's why it's safer to use if(happy) instead of if(happy==1), and if(!happy) instead of if(happy==0).

If you think now that you're certain of what happy will always be, think again. Robust code means you have to prepare for something going wrong at some point, perhaps because you made a change somewhere else. Maybe you'll decide to change the code so happiness has degrees of intensity; if you do, then any code you designed robustly will handle the change well even if you forget to modify it.

Missing the Mark

Another problem that can occur in code is when you're testing a number to see if it's fallen to 0. Consider this case:

mob/proc/DeathCheck(mob/killer)
if(!hp)
src << "Oh dear! You've been slain by [killer]!"

Problem? Not if hp falls to exactly 0 when you die. But if your attack routine doesn't check for that, or worse, if you change it and forget that you're supposed to check, then hp could become a negative value. If you have 4 hit points and are hit with an attack that takes 6, hp is -2 which is still a "true" value.

The clear solution is to replace if(!hp) with if(hp<=0), which makes the death check work even when hp is accidentally negative.

But since this is really about robustness, just telling you to use one operator instead of another isn't enough. In the example I asked what would happen if your attack routine changed and you forgot to do an important check there. Well, imagine the opposite: Imagine your death check changed and you forgot something in that. Wouldn't it be nice to send it the cleanest, safest data possible? That means that we have to cover the attack as well.

mob/proc/Attack(mob/target)
// speaking of robust code, we'll cover this if() a little later
if(!target || !istype(target)) return
var/hit = rand(1,5)
target.TakeDamage(hit, src)

mob/proc/TakeDamage(damage, mob/attacker)
hp = max(hp - damage, 0)
DeathCheck(attacker)

Take a look at the line that changes hp. If damage is greater than hp, then hp-damage will be a negative number. Since we want to avoid sending that to DeathCheck() (or to statpanels, where it'd look bad), we use max(hp-damage,0) to force the result to be 0 or more. Now even though it'd be safe for DeathCheck() to use the original if(!hp) line, we'll still use if(hp<=0). That way if one proc fails, the other one can back it up.

Failsafes are the most important aspect of robust code. If your procs somewhat distrust each other to send reliable information, they'll get along better.

It's time to talk about the biggest source of bugs: assumptions. The author who wrote if(!hp) didn't necessarily think that hp would never be negative. Likely as not he just never thought about it. We think of hit points as a nonnegative value, and may not realize that negatives could crop up simply because of the math done in the game. But it's also probable that this mistake came from thinking about just one thing at a time: "How do you know if you're dead?" "You have 0 hit points." And in so thinking, the author may have just forgotten that during the killing itself, hp-damage could be negative. He's accidentally made an assumption, then, that hp>=0.

There's another assumption above, and it's a hard one to spot. TakeDamage() assumes that damage>=0. What if it's not? That would be a healing effect.

Like if(!hp), you can make a similar mistake with healing. If you check don't check hp against its maximum value, you can overshoot and become too healthy. Since this is really related to what happens when you subtract from hp, it's best to combine these tests.

mob/proc/TakeDamage(damage, mob/attacker)
hp = min(max(hp - damage, 0), maxhp)
DeathCheck(attacker)

You see, TakeDamage() wasn't as safe before as I could have made it. Using TakeDamage() with a negative amount of damage is a great way to implement healing, but even if it was never intended for that, a variable-damage attack might accidentally cause a similar problem.

Peskidecimal

Another source of bad assumptions when it comes to math is that you're always working with whole numbers. Since all numbers in DM are floating point, they can have decimal parts. Those decimals could be entered by a player during input(), or by doing other math--especially division. Here's an example of a really easy-to-abuse bug:

obj/game/verb/Play()
var/bet = input("How much will you wager?", "Game") as null|num
if(!bet || bet<=0) return // deal with Cancel, and negatives
if(bet > usr.money)
usr << "You don't have enough money!"
return
if(prob(49))
usr << "You won $[bet]!"
usr.money += bet
else
usr << "You lost $[bet]."
usr.money -= bet

So what's the bug? We checked if the player clicked Cancel, we checked if the bet is negative, and we checked for a player trying to bet more than they have. What'd we miss?

Decimals. A player with just $1 can wager 0.5 and still have money if they lose. Then it can go to 0.25, 0.125, and so on, without ever hitting 0. A player with a winning streak can win everything back.

The solution is to only allow bets in fixed increments, like 1. For that you'd use bet=round(bet) to round down. No player can bet 0.75, then, because it counts as 0.

And on the subject of money, there's another problem with decimals. In BYOND's floating point math, 0.01 is not an exact number. (If you understand binary then you probably know why: 1/100 has infinite repeating bits in binary form, but floating point only uses 23 bits of the actual number.) Whenever you work with these amounts, you should use round() a lot.

proc/SalesTax(amount, tax_percent)
amount = round(amount * 100, 1) // a whole number of cents is safer
return round(amount * tax_percent, 100) / 10000

Bail Out!

The single easiest error to fix in your game is "Cannot read null.var". This occurs when a value is null that isn't supposed to be, like an argument to a proc. Consider this common case:

mob/verb/Attack(mob/M as mob in oview(1))
// by the way, this is the suckiest attack formula ever
var/hit = max(0, usr.attack - M.defense)
if(hit > 0)
hearers() << sound('hit.wav')
M.TakeDamage(hit, usr)

So what's the problem here? Well, it turns out that Attack is such a common command in BYOND games, a lot of people have a macro for it. If you use the macro quickly enough, these commands get stacked up in a queue waiting to run, while you keep hitting the monster. Suppose the monster dies, and is deleted, while those commands are stacked up: Suddenly M becomes null, but the verb will still run. Attempting to read M.defense will result in a "Cannot read null.defense" runtime error.

The solution is to bail out if M is null. Bailouts are a great way to escape code that is going nowhere fast.

mob/verb/Attack(mob/M as mob in oview(1))
if(!M) return
// let's at least improve the stupid formula too
var/hit = max(0, roll(usr.attack) - roll(M.defense))
if(hit > 0)
hearers() << sound('hit.wav')
M.TakeDamage(hit, usr)

Well it turns out there's still a problem with this. Sure, if you kill a monster the attacks that follow will just short-circuit. But that's not enough! What if M is another player, and instead of being deleted they reappear somewhere else? Then M is no longer in range, and still takes hits. Even though the in oview(1) clause limited what you could hit in the first place, circumstances can change in the split second between the time a command is given and when it is actually executed. Let's try something else.

mob/verb/Attack(mob/M as mob in oview(1))
if(!M || !(M in oview(1))) return
...

Pay special attention to the parentheses around M in oview(1). You need them, because the in operator has a much lower precedence than ! does. In layman's terms, that means the ! gets processed first. Basically that would screw up the whole if(), and the bailout wouldn't work.

While You Logged Out

A funny thing can happen at unexpected times to screw up your game: A player can log out. This can happen any time code sleeps or waits for some input. All kinds of disasters can come of this if you're not prepared. Most games delete players' mobs when they log out; in others, just the client becomes null, and that can be enough to cause an error.

mob/proc/HappyFunBug()
var/answer = input(src, "What do you think?", "So") as text
var/buggy = input(src, "If you logged out during that last input(), \
you'll cause a \"bad client\" error. I hope you're \
happy."
, "Bug") as text


Actually a whole lot of bugs can come from logouts during input(). If you're not prepared to deal with a Cancel choice (there'd be a Cancel button if you used as null|text), the answer could unexpectedly be null if the player does a "hard cancel" by quitting. Another fun case is when the player's mob is deleted entirely by a logout; even if you don't call input() a second time, odds are you probably need the player to still be around after giving their answer.

In any case the best way to deal with the possibility of a logout during your proc is to do another bailout check.

mob/proc/HappyFunBug()
var/answer = input(src, "What do you think?", "So") as text
// if this was usr instead of src, you'd have to make this line
// say if(!usr || !usr.client) instead
if(!client) return
var/buggy = input(src, "Aww, there's no bug to play with.", "Bug") as text

But input() isn't the only place to watch out for this. Any time your proc uses sleep() you run the risk of a client becoming null. And of course, if anything else can be deleted such as the mob itself or its contents, you run into problems too. Take this example:

mob/Logout()
Save()
del(src)

mob/proc/TripleHit(mob/target)
if(target)
target.TakeDamage(3, src)
sleep(5)
target.TakeDamage(3, src)
sleep(5)
target.TakeDamage(3, src)

During any of those sleep() calls the target could log out, or maybe just be deleted outright if it's a monster. Actually this whole proc is wrong, because it doesn't check first if the target is valid or even in range, and it doesn't check again each time.

Not My Type

A common source of trouble is when a proc expects arguments of a certain type, and gets something completely different. This can lead to type mismatches, undefined vars, you name it.

mob/Bump(mob/M)
Attack(M)

Well that's just brilliant, isn't it? What happens if M is an obj or a turf? But wait, it's always supposed to be a mob, isn't it? I even said so! Heck, I'll even make it mob/M as mob and force it to be a mob!

Unfortunately the as clause only works for verbs. You can't restrict your arguments to a particular type. (And for verbs, you can't even restrict them to a subtype like /mob/monster.) DM is not a strictly typed language. That means it doesn't care if you send a proc the wrong kind of argument; it assumes you know what you're doing when you say M is a mob.

mob/Bump(mob/M)
if(ismob(M))
if(M.is_npc) M.Talk(src)
else Attack(M)

Now we have a better Bump(). It checks first to see if M is really a mob, not a turf or obj or (a very weird case) null. To get more specific, you may need to use istype().

// src==usr in mob verbs by default
mob/verb/Attack(mob/monster/M as mob in oview(1))
if(!istype(M)) return // compare M to /mob/mobster
...

Of course if you only want to attack monsters there's a better way to do it anyway.

// now usr is the attacker but src is the victim
mob/monster/verb/Attack()
set src in oview(1)
...

When you have arguments that must be a particular type, a little type checking never hurts. DM has a lot of handy procs for that.

  • isnull()
  • isnum()
  • istext()
  • isfile()
  • ispath()
  • isturf()
  • isobj()
  • ismob()
  • istype()

Wrapping Up In Titanium

So basically, writing robust code is pretty simple. It just takes a little thinking ahead, which you can do mostly by just remembering a few rules of thumb.

  • Remember failsafes! Just because something should never happen doesn't mean it won't.
  • Check your data--before you send it to another proc, and in the next proc that receives it. If proc Daffy() calls Porky(), just pretend Daffy() thinks Porky() is the village idiot who can't do anything right, while Porky() thinks Daffy() is a vicious weasel trying to kill it with tainted food. Daffy() will try to keep its instructions clear and simple, and Porky() will keep an eye out for data it can't digest.
  • When you use true/false values, keep your conditional statements simple with statements like if(is_ready) and while(!dead).
  • If you're checking something against an upper or lower limit, don't forget the <= and >= operators.
  • max() and min() are a great way to clip numbers to a range you expect.
  • Sometimes verbs get called with null arguments, like a mob that was just deleted. Don't assume they're valid until you check them.
  • If you think a number is always whole, or positive, it may not be either. In fact depending on where you got it, it might not even be a number! Use round() when you need to, and always check for negative values wherever they're not supposed to be used.
  • BYOND rounds off numbers to a certain precision when it does math, so rounding errors can creep into your code. (1/7)*7 is not exactly 1.
  • Logouts while code is sleeping or waiting for a player's response can cause things like usr.client to change. Be prepared!
  • Players are sneaky and can screw you by putting something you didn't expect into input() or a URL handled by client/Topic().
  • Vars and arguments may not always be the type you think they are. When you tell DM what type they're supposed to be, it just assumes you're right. Check the type, especially in procs like Bump() whose arguments are out of your control.
You need to correct both HTML and entity problems in this. :P
Hmm, I've corrected the entity problems now, but I only know of two HTML problems: the dm tags aren't highlighting some of the syntax properly, and I can't figure out how to make the "Did You Know?" sidebar into a sidebar. But thanks for pointing out the entity stuff!
Yeah, Other then that the article helps a lot when making a game, I found it usefull and fixed things in a mini game.