02MORPG: Making an MORPG with BYOND

by Ter13
Learn to make a MORPG with BYOND with no existing knowledge
ID:2149220
 
In the first tutorial, we learned how to use Dream Maker at a basic level. We created a rudimentary multiplayer game where you can walk around. Sure, it doesn't look very good, and isn't very functional, but this tutorial will start to introduce the concepts you need to know to be able to add on to what we made and fix some of the problems with our first attempt.




2.1: Building a Foundation

  BYOND is an object-oriented programming language. We talked a little bit about objects in the last section, but only went over what we needed to know in order to use them to create something basic. It's time we spent some time talking about what objects really are and how they benefit you as a budding programmer.

  As mentioned before, objects are data just like primitives. Unlike primitives, however, they can store values in the form of variables and they can also have functions that belong to them in the form of procs and verbs. Objects make modular programming much easier by ensuring that code and variables stay with related code and variables. Objects have their own unique namespaces. Namespaces are zones of memory that contain values that you can access by name. In the last example, we saw that changing the turf's icon variable and the mob's icon variable did different things in our final result. That's because the icon variable exists in the namespace of each object, and the objects are independent of one another.

  What's really going on, though, is that objects have two forms, called prototypes and instances. Imagine an object prototype as sort of a blueprint that tells an object what it does. When we write code in Dream Maker, we're actually defining that object prototype. When we run the code in Dream Daemon, though, many instances of those object prototypes are created. For example, the map we made tells Dream Daemon which type of turf to put where, and how many turf instances to create to draw the world. Also, when a player logs into the world, a mob instance is created for that player.

  Prototypes themselves house the default state of all variables defined under them. When an object instance is created, it gets its own unique copy of all variables, whose default values contain copies of the values stored in the prototype's variables. This means that instances are mutable! Mutability means that you can dynamically change aspects of an object instance, and other object instances will not respond to those changes. This is what variables are, mutable regions of memory that store values. When a variable belongs to an object instance, all other object instances having that variable are left alone, and the prototype's version of that variable is also left alone. Prototypes cannot have their variables changed dynamically, as prototypes are considered to be immutable.

  Prototypes also have functions that belong to them. Object instances refer to the prototype's functions to determine their behavior when these functions are invoked. Instances don't actually receive copies of the functions. The functions only exist on the prototype, but the instance refers to the prototype when methods are invoked on it.




  Let's get started modifying our project a little bit by creating a new subtype, or child prototype of turf. This subtype of turf is going to have different properties than its parent type. But first, what do we mean by all this? Object prototypes are polymorphic in nature. This means that one prototype can derive into many, many different forms with their own unique properties (variables) and behaviors (functions).

  Polymorphism is best understood by looking at biology. When species evolve, they create a sort of tree of subspecies that have different forms over time. A species can have two subspecies that are like their parent species, but unlike one another. Both will have common features with their parent, but potentially different features from one another. This is polymorphism in a nutshell. The idea is that a child prototype inherits the structure of their parent prototype, but also can implement some new behavior that the parent doesn't have.

  BYOND uses a tree-like format to do all of this, which is why we also call it the object tree. BYOND makes deriving new object prototypes very easy compared to other high level languages, and also gives you a much more obvious visual representation of how objects inherit from one another using something called paths.

turf
icon = 'Graphics/Environment/tiles.dmi'
icon_state = "0"

water
icon_state = "1"
density = 1 //you can't walk through water


  We just created a new prototype under the turf prototype. Paths refer to the inheritance chain of a particular prototype. The water prototype's path is /turf/water. /water is a child type of /turf, and thus inherits all default values set in /turf. That means that it inherits the value in icon and icon_state, as those are the only two values we set under /turf. Water has another value that turf does not: density. We're setting it to 1, which means that this turf is dense. When something is dense, it blocks movement over it. We also repeated icon_state and changed it to "1" in /water. This is called an override. The value of icon_state for /water will be the last one that it was set to. And by the last one, we mean the last one in order. You can override a value any number of times, but only the last one will have an effect. Since we overrode it in /water, it doesn't affect that value in /turf because child prototypes only inherit from their parent. The parent is unaware of the changes you are making to their children.

  Don't forget to add graphics for your water in the tiles.dmi file, and also don't forget to name the new icon_state! Once you've done that, jump back to world.dmm. Notice that the object tree view is active on the left whenever you open a map? That's because the map editor lets you place objects on the map. If you press the "+" box next to turf, you can open that portion of the tree and see the child types of /turf. Our water should show up. If you don't see it, you forgot to compile (Ctrl+K).



  Just plop some water down on the map wherever and run your project. Now you have a slightly more interesting map with obstacles the player can't walk through!



  Also, do you see that bit of text in our code that starts with //? That's called a comment. The compiler ignores everything on that line after the //. You can leave yourself little notes in your code with comments to help people understand what's going on, or even reminders for yourself. There's another form of comment called the block comment:

/*

everything between these characters is a comment.

*/


  Block comments are useful for disabling pieces of code and leaving longer, better formatted comments in your code. They can go almost anywhere and the compiler will completely ignore them as well.




  We can also use the map editor to create instanced prototypes. The object tree shows hard-coded prototypes, but we only need to define hard-coded prototypes when new behavior is added to a prototype. That's because the map editor allows you to edit the variables of any prototype and use them on the map. Let's remove the /turf/water prototype from our code and go back to where we started. Compile and go back to the map editor.



  Unfortunately, you can't just delete prototypes all willy nilly. The map doesn't know what to do. This is a simple fix, though. Just replace the /turf/water prototypes on the map with /turf prototypes. We do this by clicking on New path box and replacing the text in that box with "/turf". Click OK.



  Compile the project again to clear the errors. Now you can actually add your water tiles directly with the map editor. Select the turf prototype in the object tree viewer and then right click in the object window on the right. This will open the instance editor window. The instance editor window allows you to change any of the variables of a prototype to create a new instance prototype with different values than the parent type. You can only override values. You cannot add more. You also cannot change behavior here. Change the density and icon_state variables to reflect the changes we made earlier when creating the /turf/water type and then press OK.



  Congratulations, we just created a new prototype. Go ahead and compile. Uh oh! Our prototype just disappeared! Why? Well, because the map editor only preserves instances that are in use. If there isn't a copy of that prototype on the map, it will be cleared on compilation. Go ahead and recreate the prototype, then select it and start plopping them down on the map. Compile. This time it stayed! Run the project, and you'll notice that it looks exactly the same as having a different type!

  Both methods of creating new types of object instances are acceptable, but the smaller your code is the easier your project will be to maintain. It is better to have a smaller object tree with lots of variation than it is to have a larger object tree with no variation. This will make your game more flexible in the long run and make you think more like a true object oriented programmer.

TODO: Expand on mapping. Ctrl+shift+click. Object Instance Edit vs Object Prototype Edit. Zoom. Icon Types. Click Behavior. Nudging. Alt+Click. Layers. Find/Replace. Selection. Boxes. Map resizing.




2.2: Bump and Grind

  Our little game doesn't have much to do. Let's change that. We're going to add enemies that the player can kill by bumping into them, and learn a bit more about movement in the process.

  Functions can spit out values when invoked/called. This is called returning. In this way, functions can be used to create values for storage or testing. When a function returns a value, it immediately stops executing any code below the return, and Dream Daemon's interpreter returns to the last instruction that was being processed when we called the function. That's why it is named return. The interpreter can only process one thing at a time in sequence. Functions cause the interpreter to jump around to the function body and execute all of the commands in that function in sequence until a return is encountered. A function call/invocation sort of pauses the current function that is being processed by the interpreter and jumps into the instructions contained in the function's body and begins processing them one by one. Returning allows the function that called the current one to continue where it left off, potentially using the value that was returned as part of the other function's behavior.

  When something moves in your world, the Move() function is called. All mobs have the Move() function. The move function first checks whether the mob can move into a space by calling Exit() on the turf it is standing on and Enter() on the turf it wants to move into. For any mobs or objs (a type we haven't covered yet) in the space we are leaving, Uncross() is called. For any mobs or objs in the space we want to enter, Cross() is called. Exit(), Enter(), Cross() and Uncross() are all functions that return values. They return 0 on failure and 1 on success. If any of these functions returns 0, Bump() is called on the object(s) that prevented movement from succeeding. If all of the functions return 1, the Move() function moves the object then calls Exited() on turfs no longer being stood on, then Uncrossed() on all objs/mobs no longer being stood on, then Entered() on turfs newly being stood on, and Crossed() on all objs/mobs newly being stood on. Move() then returns the number of pixels moved.

  We want the player to be able to attack monsters that they bump into.

  First, we should differentiate players from monsters by creating child types for each derived from /mob.

mob
player
icon = 'Graphics/Sprites/player.dmi'
monster
icon = 'Graphics/Sprites/goblin.dmi'


  But you'll notice when you compile and run this code, that you no longer have a graphic when you log in! That's because when a player logs in, the world doesn't know what type of mob to give them. It doesn't know /mob/player exists. So we have to tell it by overriding the world.mob variable:

world
icon_size = 16
mob = /mob/player


  Now when you log in, you will have a graphic again. The world knows that players should be /mob/player instances. Remember what we said about movement? We want players to kill monsters when they bump into them? We need to override /mob/player/Bump(). We can change the behavior of a function that already exists just like we did with the Login() function. This is again, called overriding. Bump() isn't just called for monsters though. Bump() is called whenever the player bumps into anything. We need to be able to check if the object being bumped into is a monster and then tell it to die. Dying isn't a built in behavior BYOND provides for you. However, objects can be deleted. We want to delete any monster that the player bumps into after we confirm that the object being bumped into is actually a monster.

mob
player
icon = 'Graphics/Sprites/player.dmi'

Bump(atom/o)
if(istype(o,/mob/monster))
src << "You kill [o]"
del o


  We've just introduced a new concept called if() statements. if() statements look like functions, but they actually aren't. They are one of the block-level statements. Block level statements divide instructions inside of functions into different blocks. Each block level statement handles those blocks differently. The if() statement is used to only execute code if the condition inside of the parentheses is true. Anything tabbed under the if() statement will execute if the condition is true. Otherwise, everything under the if statement is skipped.

  istype() is a built-in function. It will check if the supplied object instance derives from the supplied object prototype. Notice that we are using "o" for the object, and /mob/monster for the prototype. o is a special type of variable called an argument. Arguments are variables that can be passed into a function call. The variables we have worked with up to this point are instance variables and belong to an object. o is an argument and all arguments are local variables. Local variables only exist in the body of a function. You can't use them outside of the function. Bump's argument contains the object instance we bumped into as a value by default. /mob/monster is again, a path. istype() takes two arguments, an instance and a prototype. o stores an instance, and /mob/monster is a path that refers to the /mob/monster prototype. Hence, Bump() has one argument, and istype() has two arguments.

  Notice the argument is "atom/o", well that's not a path. /atom is a path. In fact, the /atom prototype is the parent type of /areas, /turfs, /objs, and /mobs. It is the root type of all objects that be seen on the map. When we include the type before the name of a variable or an argument, it's called a typed declaration. The variable will believe that whatever it contains is an instance of /atom, as far as the compiler is concerned, and it will prevent certain errors that would be caused by accessing variables or invoking functions on that variable that are defined as part of a prototype. If you invoke functions or access variables that are not declared, it will not compile because the compiler can't be sure that is safe behavior. That's why we are typing the argument declaration. To inform the compiler that what's stored in o is going to be an /atom, and we should treat it as though it has all of the properties and behaviors of an /atom. If the value passed into o while the proc is actually running in a game isn't actually a type of /atom, this will result in what's called a runtime error. Compiler errors happen when you try to compile. Runtime errors happen whenthe game is running.

  When we invoke istype(), we are checking whether the instance is derived from /mob/monster. This will return true for any instance of /mob/monster or any children derived from it, and false for any other type of instance. The if statement will process the return value of the istype() invocation, and if it is true, execute the code tabbed into the if statement.

  We're using the output operator again inside of that if statement, but that's all you should recognize of that line at this point. We are outputting a string to something called src. Remember earlier when we went over the difference between instances and prototypes? Well, the function Bump() belongs to the prototype, not the instance. So we need some way of figuring out which instance is the one that triggered the Bump() call. That's what src is. src is the instance that the function was called on. The prototype owns the function, and the instance uses the function the prototype owns. src is the instance currently triggering this function call. Therefore, src << means we're outputting a text string to the current player in this case, since the /mob/player object is the object that represents the player. Neat, right? We don't have to send text to the entire world!

  There's also something different about that text string. "[o]". Those square brackets inside of a text string are what's called an embedded expression". An embedded expression is a way of putting variables and code into a string to create a string differently for whatever is embedded in the brackets. We're putting the reference to the object instance stored in the argument o, which is the object we bumped into. BYOND, by default will print the object's name. If the name is lowercase, it will also insert "the" before it. Names are set by default to the name of the child type. This can be changed by overriding the name variable for that subtype. Which in our case, name will be "monster".

  Lastly, there's a word in there that we don't recognize. "del". del is shorthand for delete. This will instantly destroy the object that follows this word. Be careful, though. BYOND provides the del keyword as a convenience to the developer, but it should not be used in large projects. For now, it is fine. Later on, you want to avoid explicitly deleting objects wherever you can.




  This is all good and great, but let's learn a bit more about object-oriented programming. Objects should handle their own behavior. This means that all the code relating to the interaction of two objects should go in one proc or the other. If the behavior is a property of only one object, we should not put it inside of another object. Polymorphism is a very important concept that states that we should always be as generic as possible when choosing to implement new interfaces. Let's clean up what we just did a little bit and take advantage of polymorphism a bit better.

mob
proc
Die(mob/killer)
loc = null


  In the above example, we have created a new function under the mob prototype. We create new functions by using the proc keyword under a prototype. Functions in DM are called procs, which is short for procedure. This means that all mobs can now die. This will set their current location to null when it is called. It also has one argument, which is "killer". This will refer to the mob that killed this one when Die() is called. null is a keyword that means "nothing". It is a special memory location that is used to denote that there is no value inside of that memory location. loc stores the current turf that the object is standing in and is automatically kept up to date by the engine.

  The reason we are setting the mob's location to null is because we no longer want the mob to be on the map when it has died. At the moment, objects that are not on the map get deleted by the garbage collector. The Garbage Collector is built into DM, and ensures that objects that are no longer in use by the engine are removed. This allows the application to free up memory for reuse, as memory is a finite resource. When you store an object in a variable it is called referencing the object. The Garbage collector works by keeping track of how many live references there are to an object in memory. When that number reaches zero, the object is deleted by the garbage collector automatically. The only variable in the current system that by default references other objects is loc. When you are standing inside of an object, that object also keeps track of what is standing in it in the contents variable. The contents variable stores multiple objects in an object called a list. We'll cover these in more detail later. Once you move an object out of a location and into null space, it cannot be in any other object's contents list, and thus will be collected as garbage. Loc and contents are special, though. They don't work like other variables for triggering garbage collection. Objects can also be inside of each other, and not just turfs. When an object is inside of an another object, it isn't drawn on the map. If an object containing multiple objects is moved off the map, that object will be deleted regardless of the other objects being inside of it. Every object inside of that object will be sent to null space after the container object is deleted. If any of those objects fail a reference check, they too will be deleted, and so on. This is called cascading garbage collection. This is an exception to a rule called circular references or memory islands, which we will cover later. Manually deleting objects forces the engine to search through every object instance, every list, and every variable currently in use in the world and replace any reference to the object with null. This is slow if your game is very large.

  We don't want players and monsters to do the same thing when they die, so let's take advantage of polymorphism and override the new Die() function we just created on each type.

mob
proc
Die(mob/killer)
loc = null

player
icon = 'Graphics/Sprites/player.dmi'

Die(mob/killer)
world << "[src] has died at the hands of [killer]!"
loc = locate(1,1,1)

monster
icon = 'Graphics/Sprites/goblin.dmi'

Die(mob/killer)
..()
killer << "You kill [src]"


  Oh no, something new! When the player dies, we have overridden the Die() function. This replaces the default action of the function completely. So mob/player/Die() behaves differently than mob/Die(). The player's die function sends a message to the world telling everyone of the player's shameful death at the hands of the creature that did them in. Then it changes the player's location to the turf at the coordinates 1,1,1. locate() is a function that returns an object from either the world or a specified container. locate() has many different forms, but locate(x,y,z) this is the most basic of them to get references to turf instances on the map based on the supplied coordinates. Functions can have optional arguments or even a variable number of arguments in DM to make them more flexible.

  Moving down to the monster's die proc, you'll notice a weird function called "..()". This function is actually called the supercall. The supercall function invokes whatever function that the current override overrode. In this case, this function overrode /mob/Die(), so it will call mob/Die(). If no arguments are provided to the supercall, it will use the arguments supplied to the current invocation. Cool, right? In this case, because we used the supercall, we're actually expanding the parent function. Sometimes using a supercall is referred to as "doing the default", or whatever the function was supposed to do before we overrode it. Calling the supercall is almost identical to doing this:

mob
monster
Die(mob/killer)
loc = null //..()
killer << "You kill [src]"


  Neat right? This can save you a ton of typing later. This is good because if you need to change something you don't have to track down every time you did the same thing in a dozen different prototypes lower in the tree. Less code means less room for errors and faster revisions. Polymorphism is awesome!

  With all of that out of the way, we can finally implement the bumping behavior that we want on the player!

mob
player
icon = 'Graphics/Sprites/player.dmi'

Bump(atom/o)
if(istype(o,/mob/monster))
o.Die(src) //compiler error: undefined proc


  If you compile the above code, there's a small problem. It won't compile. It's complaining about an undefined proc. Remember when we talked about declaration? Well, o is declared as a type of /atom, and /atoms don't have a Die() function. So how do we fix it? We need to inform the compiler that the object we want to call Die() on is actually a mob or one of its child types. We can do this by a process called typecasting. Typecasting is when you take the value of one variable and put it into a variable that's declared as a different type. This is useful for getting around compiler errors.

mob
player
icon = 'Graphics/Sprites/player.dmi'

Bump(atom/o)
if(istype(o,/mob/monster))
var/mob/mobster/m = o
m.Die(src) //compiler error: undefined proc


  We fixed it! Wait, what's this var keyword? The var keyword is used to declare a new variable. The var keyword can be used to add new variables to objects when part of a prototype definition, or it can be used inside of a function or a block level statement. The variable is useful in any code block immediately after it has been defined where the interpreter has run through any block preceding this one. This is called scope.

//global scope
var
someglobal

someobject
//instance scope
var
someinstance

SomeFunction()
//function scope

//{someglobal, someinstance}
var/somelocal1
//{someglobal, someinstance, somelocal1}
if(1)
//block scope

//{somelocal1}
var/somelocal2
//{someglobal, someinstance, somelocal1, somelocal2}
if(2)
//block scope

//{someglobal, someinstance, somelocal1, somelocal2}
var/somelocal3
//{someglobal, someinstance, somelocal1, somelocal2,somelocal3}
//{someglobal, someinstance, somelocal1, somelocal2}
else
//block scope

//{someglobal, someinstance, somelocal1}
var/somelocal4
//{someglobal, someinstance, somelocal1, somelocal4}
//{someglobal, someinstance, somelocal1}


  Scoping is a concept that indicates where variables are alive and how they are accessed. When not in an prototype declaration, your code affects the global scope. Global variables can be accessed anywhere at any time and are never destroyed while the world is running. prototypes are global. They always exist while the world is running too. However, variables inside of a prototype are instance variables. Instances can be created and destroyed and their variables are only valid as long as the instance exists. You can access instance variables anywhere that you have a reference to that instance. Functions also have scope. That scope only exists as long as the function is currently running. Arguments exist inside the function scope as do any variables defined in the function scope. Block scope is where things get confusing, because any variables defined inside of a block can't be accessed after that block has exited, and they don't exist if the block is skipped. The comments show the variables that can be accessed at any given line of the code above between the {} curly brackets. You'll notice that as you jump into and out of block level code, the values change, and you'll also notice that you can't access a variable above where it is declared. That's because it hasn't been declared yet. Functions operate over time in a sequence.

  Once we've declared the mob/monster/m variable, we can assign the instance stored in o to be the value of m. This is a successful typecast. On the next line, we can use m to invoke o's Die() function without triggering a compiler error.

  DM also provides an unsafe way to do all of this without declaring a variable called a runtime look-up:

mob
player
icon = 'Graphics/Sprites/player.dmi'

Bump(atom/o)
if(istype(o,/mob/monster))
o:Die(src)


  The ":" is called the look-up operator. This differs from the "." accessor operator in that it doesn't perform compile-time type-safety checks. The ":" operator simply checks if the label that is passed as the right operand is defined at all under any object in the project and if so, the compiler is satisfied. The look-up operator checks whether the right operand exists as defined under the declared type of the left operand. Because of this, the ":" operator is generally seen as unsafe. In our example, either is safe because we have already ensured that o is a type of /mob, and all mobs have a Die() proc. However, be forewarned that should you for some reason decide to change the name of Die(), you won't necessarily get a warning from the compiler that it is in use on the line where the look-up operator is used to invoke it. This can make refactoring more frustrating. As such, it is generally considered better to always use the accessor operator when possible.

  Let's take a look at our completed code all together:

world
icon_size = 16
mob = /mob/player

mob
proc
Die(mob/killer)
loc = null

player
icon = 'Graphics/Sprites/player.dmi'

Bump(atom/o)
if(istype(o,/mob/monster))
var/mob/monster/m = o
m.Die(src)

Die(mob/killer)
world << "[src] has died at the hands of [killer]!"
loc = locate(1,1,1)

monster
icon = 'Graphics/Sprites/goblin.dmi'

Die(mob/killer)
..()
killer << "You kill [src]"


  Compile, add some monsters to your map, and run around bashing their brains in!




2.3: Seek and Destroy

  Okay, so what's the point? We can run around and kill monsters, but that's not really a game. Let's make the monsters do things. For now, they will just wander around and look for players to kill. AI is hard. Be aware that this section will be fairly advanced and will cover a lot of concepts in a very short span. Get ready, take a deep breath, and don't forget your towel!

  AI is a difficult concept to tackle, especially for a beginner, but it's also a concept that requires very little in the way of actual familiarity with any APIs to tackle. All it requires is some basic programming skill, and we've already learned most of what you need to tackle it. We just need to gain a fuller understanding of the way the interpreter works over time and a few details about BYOND's movement functions. Oh, and loops. We need to know about loops.

  We can think of AI as a loop that is always trying to do something. Depending on what's going on in the game, ai monsters can choose to do a number of things. The simplest AIs have a list of things that they can do and will always do a particular way. More complex AIs will tend to introduce some degree of chaos into the mix to fool the player into thinking that the AI is learning or adapting. We're aiming for a simple AI that will remain inactive whenever the player isn't nearby, will wander around a bit when they are in the area, and when they get close, they will chase the player and attempt to attack them.

  We want our AI to be able to take advantage of polymorphism, and we also want our AI to be somewhat tunable per instance, so we can introduce a little bit of variety for each type of monster. Go ahead and create a new code file and call it "ai.dm"

mob
monster
var
turf/home_loc
ai_state
respawn_time = 100

New(atom/loc,state)
home_loc = loc
ai_state = state||ai_state||"idle"
AI()

Die(mob/killer)
..()
killer << "You kill [src]"
ai_state = "dead"

proc
AI()
set waitfor = 0
while(ai_state)
switch(ai_state)
if("idle")
Idle()
if("dead")
if(home_loc)
sleep(respawn_time)
loc = home_loc
ai_state = "idle"
else
ai_state = null
else
ai_state = "idle"

Idle()
while(ai_state=="idle")
sleep(world.tick_lag)


  This bit of code introduces quite a few new things. We create three new variables for the monster, the first is called home_loc and it stores the turf that serves as a respawn point for the monster. The second is ai_state, and it serves to store the current set of actions the AI scenegraph is undertaking. The third is respawn_time. This stores how long this monster will take to respawn after being killed. How long is 100? Well that's 100 ticks. BYOND measures time in ticks. Ticks are by default 1/10th of a second, or 100ms. 100 ticks is 10000ms, or 10 seconds. We also overrode a function called New() on mob/monster. New() is called whenever an object is created. If the object is on the map, it will be called whenever the world starts up. By default, atom/New() has a single argument, loc. Our new New() function will set the home loc to the current location of the monster on creation. We've added a second argument for creating new monsters that start out in a different ai_state than normal.

  Within New() we set the home location to the loc argument, and we set the ai_state variable to... Wait what? What is that || doing in there? The || operator is called the logical or operator. It's really cool. If the left operand is non-true, the right operand will be used instead. If the left operand is true, the right operand will be discarded. It's sort of like using an if statement to set a value, but without the if statement. You can chain them together to say use these values in this order, with the first value being chosen. Finally, we call the AI() function.

  Die() has been changed to set the ai_state to "dead". Let's jump down to the AI() proc. This will be doing most of our work. The AI() proc has something called a setting in the first line. In DM, settings change how functions work. One of these settings is called waitfor. Remember when we learned that the scheduler can only do one thing at a time until the function it is working on returns? What if we need to have a function that runs for a long time? That's what the waitfor setting is for. The waitfor setting keeps functions from hanging up the interpreter and allows them to return a value before they have actually finished. This is a really strange concept, I know, but it makes programming in DM unique and easy.

  while() is also new. while() is another block-level statement. Anything inside of the while loop will run over and over again until the condition is no longer satisfied. In our case, the while loop runs until the ai_state variable of the monster is null. As long as this function is running, the monster will never be garbage collected, because it runs forever as long as ai_state is not null. When a loop repeats, that is called one iteration.

  switch() is another new block-level statement. switch() is a group of if statements of which only one contained if statement is used at a time. It is a way to test whether a supplied value matches one of a range of values and perform a specific block of code given the value supplied. Our switch statement does one of three things: on "idle", it will run the Idle() function. on "dead", it will handle the dead logic. on any other value (else) it will set the ai_state to "idle". When the bottom of the while loop's code block is reached, the loop will instantly repeat, or iterate.

  The dead logic checks to see if a home_loc was set. If there is no home_loc, the else state is used, which sets the ai_state to null. The next time the while() loop iterates, it checks the condition: ai_state. It will be null. This will cause the AI() function to halt. if we do have a home_loc, sleep() is executed. sleep() is a statement, not a function. sleep() causes the execution of a function to halt for a set number of ticks before continuing to run the code in the function. Useful, right? Other things will still run while a sleep() is waiting. If a loop runs over and over again without sleeping, it will actually block everything else in the world from happening. The waitfor setting actually affects how sleep() is handled. Since AI is set to set waitfor = 0, anything that called the AI function will not wait around if AI goes to sleep. This also counts for any function called from AI itself that does not have the waitfor setting turned off. After this sleep, the mob is moved back to the home_loc and then its ai_state is set back to idle.

  The Idle() function currently does nothing but wait for one tick every tick while the ai_state is still equal to idle. See the == operator? That's called the equality operator. It's used for checking if two values are actually the same. This is not to be confused with the assignment operator. The equality operator is two equal signs in a row and evaluates to true should the left and the right operand be the same. world.tick_lag is a world variable that tells you how many server ticks long a world tick is. This is somewhat confusing, but don't worry about it too much. It'll make more sense later on when we really start messing with time.

  Okay, so what do our AI do now? Nothing. They just stand there. If you kill them, they respawn. That's a bit of an improvement I guess, but really, it's not exactly AI. Let's expand on the AI system by adding a new behavior: Wandering around.

area
var
players = 0

Entered(atom/o)
if(istype(o,/mob/player))
++players

Exited(atom/o)
if(istype(o,/mob/player))
--players


  Areas are a brand new type of object. We've mentioned them briefly before, but areas serve as a way to divide the world into distinct regions containing many turfs. When an object moves into an area that it isn't already in, it will call area.Entered(). When an object moves out of an area, it will call area.Exited(). What we've done is add a variable for keeping track of the number of players in an area. This will let us keep track of whether AI should wander around or stand still. If there's nobody around, why bother wandering around? It's just a waste of resources keeping track of stuff nobody is ever going to see.

  The ++ and -- operators are called the Increment and Decrement operators. This operator adds one to the operand on the right, or decreases one from the operator on the right. More specifically, these are the pre-increment and pre-decrement operators. There is another operator that looks a lot like this one, but it is called the post-increment and post-decrement operator. ++x and x++ are slightly different in how they are processed as part of an expression. the pre-increment operator increases the value of the right operand and returns the value of the operand. The post-increment operator returns the value of the operand and then increases it. The difference doesn't sound like a lot, but it is a useful distinction to know:

var/x = 5
world << x++ //outputs 5; x becomes 6
world << x-- //outputs 6; x becomes 5
world << ++x //x becomes 6; outputs 6
world << --x //x becomes 6; outputs 5


  Now that we have a way to keep track of whether a monster should wander around, we can change the monster's Idle() state and the monster's Wander() state.

mob
monster
var
wander_dist = 7
wander_delay = 5

proc
AI()
set waitfor = 0
while(ai_state)
switch(ai_state)
if("idle")
Idle()
if("wander")
Wander()
if("dead")
if(home_loc)
sleep(respawn_time)
loc = home_loc
ai_state = "idle"
else
ai_state = null
else
ai_state = "idle"

Idle()
var/area/a = home_loc&&home_loc.loc
while(ai_state=="idle")
if(!a||a.players)
ai_state = "wander"
return
sleep(world.tick_lag)

Wander()
var/turf/t, area/a = home_loc&&home_loc.loc, delay

while(ai_state=="wander")
if(a&&!a.players)
ai_state = "idle"
return

t = get_step_rand(src)
if(t&&(!wander_dist||!home_loc||get_dist(home_loc,t)<=wander_dist))
Move(t)
delay = wander_delay
else
if(home_loc)
dir = get_dir(src,home_loc)
delay = world.tick_lag

sleep(delay)


  We have added two new variables to mob/monster, wander_dist and wander_delay. wander_dist will set the maximum tile distance from the home point that this monster can walk. wander_delay stores how long in ticks that the monster will take to move one tile while wandering.

  The Idle() proc first... Wait, another operator? The && operator is the logical and operator. If the left operand is true, the right operand is used. If the left operand is false, the right operand is discarded. We are using it to only attempt to access home_loc's loc variable if home_loc is not null. This keeps us from running into a nasty runtime error for monsters that don't have a home location and therefore don't respawn. If home_loc is null, attempting to access properties of null will generate a runtime error. So, we circumvent this nasty runtime by ensuring that home_loc will always be true before we attempt to access. An if statement would also work, but why bother when an operator will do? the home_loc should be a turf. Turfs store the area that they are part of in their loc variable, and turfs live in the contents list of their containing area. So we are getting the area that the monster lives in so we can know when players are in it. Also of interest is the ! symbol before a in the if statement. The ! symbol is another new operator. It is called the negation operator. If the right operand would be true, the negation flips it and returns false. On the other hand, if it would be false, the negation flips that and returns true. The negation operator returns the logical opposite of what would the operand would be evaluated as.

 &esmp;Carrying on, we've only really added a new if statement to the Idle function. It simply checks whether or not the area the monster spawns in is active or not. If the area has had a player enter it, it we swap the ai_state over to "wander" and return, which throws us back into the AI() loop.

  The AI() loop will then take back over and call the Wander() function. The Wander() function starts out by declaring three variables on one line. If you use a comma, you don't have to type var/ over and over again. You can just declare multiple variables on the same line as a shortcut. This function works almost exactly like the idle loop, but introduces some new behavior.

  First, we check if the area containing the home_loc exists and also doesn't have any players using the negation operator. If so, we swap states back to idle and return, which will take the interpreter back to the AI() loop. Otherwise, we carry on in the while loop's code block and use a new proc, get_step_rand(). get_step_rand() is from a family of built-in functions that allow you to get a location in reference to an object attempting to step a particular way:

  • get_step(ref,dir) - get a turf in a specific direction from ref.
  • get_step_rand(ref) - get a turf in a random direction from the supplied object, ref.
  • get_step_away(ref,trg,min) - get the turf, attempting to step ref at most min tiles away from trg.
  • get_step_towards(ref,trg) - get the turf in the direction toward trg from ref
  • get_step_to(ref,trg,min) - get the first turf along the closest path to within min tiles from trg from ref avoiding obstacles


  We store the turf that a random step would result in in the local variable t, then we check if t is not null AND either there is no limit to wander_dist (0) or there is no home_loc or the step we calculated is out of the allowed wander distance. If any of these conditions are true, we move to the location stored in t, and set the delay local variable to the wander_delay. If none of the values are true, we set the delay to one tick and then sleep for the time stored in the delay local variable. get_step_rand() prefers to pick directions similar to the one the supplied movable is currently facing. In the event of a failed step pick, we actually attempt to reorient the object's direction toward their home location. Otherwise, the movables have a tendency to hit the edge of the wander area and just stop moving until the random number generator gets lucky and changes the direction to something that it can actually move to. This will keep the mob moving almost constantly.

  Directions are a topic we haven't really covered yet. Directions are numeric values that DM has special words to represent:

NORTH     = 1  (2^0)
SOUTH     = 2  (2^1)
EAST      = 4  (2^2)
WEST      = 8  (2^3)
NORTHEAST = 5  (NORTH+EAST) (1+4)
NORTHWEST = 9  (NORTH+WEST) (1+8)
SOUTHEAST = 6  (SOUTH+EAST) (2+4)
SOUTHWEST = 10 (SOUTH+WEST) (2+8)


  get_dir() is a built-in function that gets the direction between two objects in the world. Directions are something called bitflags. Bitflags are difficult to explain without a computer science background. The idea behind bitflags is that you can store multiple useful yes/no values in a single number and extract specific components using binary operations. We will cover more about binary later.

  Our little monsters should now be wandering around the world if you compile and run the project. Great job!




  So far, that's the easy part of AI covered. We're going to move on to the harder part. Chasing down enemies and attempting to kill them. Before we tackle the logic of actually chasing enemies, we're going to write some helper functions that will make the code easier to digest and maintain.

mob
monster
var
attack_dist = 1
proc
Aggro(mob/player/p)
return 1

SeekTarget(dist,atom/ref)
for(var/mob/player/p in oview(dist,ref))
if(Aggro(p))
return p

ChaseStep(dist,atom/target,evade=0)
var/turf/t, d = get_dist(src,target)
if(d>dist)
t = get_step_to(src,target,dist)
if(!t)
t = get_step_towards(src,target)
else if(d<dist&&evade)
t = get_step_away(src,target,dist)
else
t = loc
return t


  We've added one new variable and three new functions to /mob/monster. The new variable is attack_dist, which will help us determine how many tiles away from the player we can be and still attack. Aggro() is the first new function, which takes an argument of a player instance. At the moment, it just returns 1, but later we can expand it to change the rules for whether the mob can attack a target or not. SeekTarget() is the second new function. SeekTarget() is called to find an eligible target within dist tiles of ref. This function will make it easier to aggro on enemies in different situations. ChaseStep() is the last new function. ChaseStep() is used to attempt to move toward a specific target.

  for() is a new block-level statement that can take many forms. for() is like while, in that it is a loop. But unlike while(), it iterates over a sequence of values rather than satisfying a condition. This particular form of for() is for filtering a specific type of object out of a list. Each time it finds an instance of the specified type in the supplied list, it will perform one iteration of the code block inside of the for() with the variable being set to the object found. It will then continue searching.

  oview() is one of a family of functions that are useful for getting a list of all objects within a set range of another object:

  • oview(dist,ref) - get all atoms within dist visible tiles of ref excluding ref and its contents.
  • view(dist,ref) - get all atoms within dist visible tiles of ref including ref and its contents.
  • orange(dist,ref) - get all atoms within dist tiles of ref excluding ref and its contents.
  • range(dist,ref) - get all atoms within dist tiles of ref including ref and its contents.
  • oviewers(dist,ref) - get all mobs that can see ref within dist tiles from ref excluding ref and its contents.
  • viewers(dist,ref) - get all mobs that can see ref within dist tiles from ref including ref and its contents.
  • ohearers(dist,ref) - get all mobs that can hear ref excluding ref and its contents.
  • hearers(dist,ref) - get all mobs that can hear ref including ref and its contents.


  The body of the for() loop is simple enough. It will check whether we can aggro the player we just found. If so, it will return the player that was found in the list, thus ending the function immediately.

  ChaseStep() is a function that gets the turf that we should attempt to move toward based on the chasing logic. It takes three arguments, dist, target, and evade. dist is the distance we are trying to get within of target, and evade is an optional argument with a default value of 0. It specifies whether we should attempt to evade the target if we are less than dist tiles of target. The function gets the distance to the target from src, and then checks if that distance is greater than the distance we should be within of the target. If so, we use get_step_to to pathfind toward the target. If pathfinding fails (there is no path to the target), we just get a step in the direction of the target instead. Further down, we're introducing the else if block. if statements can be grouped together in if, else if, and else statements. else statements are called only if the above if statement's condition is not satisfied. Remember that in a chain of if, else if, and else statements that only one will succeed and only if all the above blocks fail. Only as many checks will be performed as are necessary to find a success. This means that conditions following a successful block are simply skipped over. After any one of the blocks finishes, the interpreter jumps past the last block in the if-else chain.

  Let's implement the last phase of our AI: The chase state.

mob
monster
var
mob/player/target
seek_delay = 10

aggro_dist = 0
turf/aggro_loc

chase_dist = 7
attack_dist = 1

chase_delay = 2


  We're going to be adding a number of new variables for our new states. seek_delay and aggro_dist are used for the wander loop. Every seek_delay ticks while wandering, the AI will look for a target player within aggro_dist. If a target is found, we enter the chase state, which uses chase_dist for the maximum range from the target, attack_dist as the minimum range from the target, and chase_delay as the time between movements. It also stores the location that the AI went on the offensive so that we can return to it when that target is gone or out of range.

        proc
AI()
set waitfor = 0
while(ai_state)
switch(ai_state)
if("idle")
Idle()
if("wander")
Wander()
if("chase")
Chase()
if("return")
Return()
if("dead")
if(home_loc)
sleep(respawn_time)
loc = home_loc
ai_state = "idle"
else
ai_state = null
else
ai_state = "idle"


  We're also adding the new states to the AI state machine. Chase() handles all of the logic for chasing down enemies, and Return handles the logic for returning back to the aggro_loc when we've decided to stop chasing targets.

            Wander()
var/turf/t, area/a = home_loc&&home_loc.loc, delay, seek = world.time, mob/player/p

while(ai_state=="wander")
if(a&&!a.players)
ai_state = "idle"
return

t = get_step_rand(src)
if(t&&(!wander_dist||!home_loc||get_dist(home_loc,t)<=wander_dist))
Move(t)
delay = wander_delay
else
if(home_loc)
dir = get_dir(src,home_loc)
delay = world.tick_lag

if(aggro_dist&&world.time>=seek) //new portion
p = SeekTarget(aggro_dist,src)
if(p)
target = p
ai_state = "chase"
sleep(delay)
return
else
seek = world.time+seek_delay

sleep(delay)


   We've added two new local variables to wander. One stores the next time that we will attempt to seek a target, and the other is for storing a found player. The world actually keeps track of time for you. It is stored in world.time. world.time is how long in ticks the server has been running, starting from 0. We can use it to keep track of when things need to happen, or store when things have happened.

  If you look below the old section of code, we have created a new section for the wander logic. Provided the monster has an aggro distance set and the world time is greater than the value store in seek, we use the SeekTarget function we set up earlier, and pass aggro_dist in as the distance, and src, or the monster instance this function is running on in as the arguments. Next, we check to see if it returned anything. If so, we set the current target to the player found and set the ai_state to chase and return, kicking back out to the AI() proc, which will then call Chase().

            Chase()
var/turf/t, fail = world.time+50

if(!aggro_loc)
aggro_loc = loc

while(ai_state=="chase")
if(target&&get_dist(src,target)<=chase_dist)
t = ChaseStep(target,attack_dist,1)
if(t)
if(t==loc)
Attack(target)
fail = world.time+50
else if(Move(t))
fail = world.time+50
if(fail>world.time)
target = null
continue

sleep(chase_delay)
else if(home_loc)
ai_state = "return"
target = aggro_loc
return
else
target = null
aggro_loc = null
ai_state = "wander"
return


  Chase() first declares a turf variable and a fail variable. Fail is again using world.time, but we are adding 5 seconds to the current value to set the failure time to five seconds from now. If aggro_loc is not set, it will be set to the current loc that the monster is standing at. while the state is chase, we iterate over a code block that makes sure we still have a target and the distance to that target is not greater than the chase_dist variable. If that is the case, we get a step toward the target using the ChaseStep() function that we set up earlier, and then we check if we got a turf back from it. If it is the current location, we attempt to attack. If the current time is greater than the failure time, we abandon the target and use a statement called continue. continue is a way to end a loop iteration early. It's sort of like return for loop iterations. The loop will attempt to continue to iterate after encountering the continue statement. Otherwise, we sleep for chase_delay ticks. If we don't have a target, or the target is out of range, we either attempt to enter the return state, or if the mob doesn't have a home_loc, we just go back to the wandering state.

            Return()
var/turf/t, fail = world.time + get_dist(src,target) * 2 * chase_delay

while(ai_state=="return")
if(target&&world.time<fail)
t = ChaseStep(target,0)
if(t)
Move(t)
sleep(chase_delay)
else
if(aggro_loc)
loc = aggro_loc
else if(home_loc)
loc = home_loc
target = null
aggro_loc = null
ai_state = "wander"
return


  Return() is a lot like Chase(). This state attempts to walk the mob back to the location that they were aggroed from. If it fails, it will teleport directly there instantly. This time, however, the failure time is based how long it will take to walk twice the distance from the current location to the target location.

            Attack()
target << "Poke!"


  Let's put everything together, compile, and then watch it in action!

mob
monster
var
mob/player/target

turf/home_loc
ai_state = null
respawn_time = 100

wander_dist = 7
wander_delay = 5

seek_delay = 10

aggro_dist = 4
turf/aggro_loc

chase_dist = 7
attack_dist = 1

chase_delay = 2

Die(mob/killer)
..()
killer << "You kill [src]"
ai_state = "dead"

New(loc,state)
if(loc)
home_loc = loc
ai_state = state||ai_state||"idle"
AI()

proc
AI()
set waitfor = 0
while(ai_state)
switch(ai_state)
if("idle")
Idle()
if("wander")
Wander()
if("chase")
Chase()
if("return")
Return()
if("dead")
if(home_loc)
sleep(respawn_time)
loc = home_loc
ai_state = "idle"
else
ai_state = null
else
ai_state = "idle"

Idle()
var/area/a = home_loc&&home_loc.loc
while(ai_state=="idle")
if(!a||a.players)
ai_state = "wander"
return
sleep(world.tick_lag)

Wander()
var/turf/t, area/a = home_loc&&home_loc.loc, delay, seek = world.time, mob/player/p

while(ai_state=="wander")
if(a&&!a.players)
ai_state = "idle"
return

t = get_step_rand(src)
if(t&&(!wander_dist||!home_loc||get_dist(home_loc,t)<=wander_dist))
Move(t)
delay = wander_delay
else
if(home_loc)
dir = get_dir(src,home_loc)
delay = world.tick_lag

if(aggro_dist&&world.time>=seek)
p = SeekTarget(aggro_dist,src)
if(p)
target = p
ai_state = "chase"
sleep(delay)
return
else
seek = world.time+seek_delay

sleep(delay)

SeekTarget(dist,ref)
for(var/mob/player/p in oview(dist,ref))
if(Aggro(p))
return p

Aggro(mob/player/p)
return 1

Chase()
var/turf/t, fail = world.time+50

if(!aggro_loc)
aggro_loc = loc

while(ai_state=="chase")
if(target&&get_dist(src,target)<=chase_dist)
t = ChaseStep(target,attack_dist,1)
if(t)
if(t==loc)
Attack(target)
fail = world.time+50
else if(Move(t))
fail = world.time+50
else if(fail>world.time)
target = null
continue

sleep(chase_delay)
else if(home_loc)
ai_state = "return"
target = aggro_loc
return
else
target = null
aggro_loc = null
ai_state = "wander"
return

ChaseStep(atom/target,dist,evade=0)
var/turf/t, d = get_dist(src,target)
if(d>dist)
t = get_step_to(src,target,dist)
if(!t)
t = get_step_towards(src,target)
else if(d<dist&&evade)
t = get_step_away(src,target,dist)
else
t = loc
return t

Return()
var/turf/t, fail = world.time + get_dist(src,target) * 2 * chase_delay

while(ai_state=="return")
if(target&&world.time<fail)
t = ChaseStep(target,0)
if(t)
Move(t)
sleep(chase_delay)
else
if(aggro_loc)
loc = aggro_loc
else if(home_loc)
loc = home_loc
target = null
aggro_loc = null
ai_state = "wander"
return

Attack(mob/player/target)
target << "Poke!"


  With basic AI out of the way, we're ready to move on and start making this more of a game! We've learned quite a lot in this section, and I don't blame you at all if you are confused and exhausted. This is a very difficult topic to cover in chapter 2 of all places. In all likelihood, this chapter is going to be moved to a later one because of the length and difficulty. However, we have introduced advanced logic blocks like if/else chains, switches, while and for loops, a number of logical and arithmetic operators, state machines, arguments, scope, return values, function settings, dealing with time, and scheduling. This is actually most of what you need to know to work in DM already. The rest is just familiarizing yourself with tasks and more of the built-in functions and learning a few more basic concepts about dealing with different kinds of data. If you find yourself struggling at this point, take a break. Reread. Experiment and try to do some things on your own for a while. This portion should be extremely difficult to grasp and it won't all fall into place at once. The next couple of sections will be much easier to swallow, so keep in mind that you have hit the first of many difficulty spikes along your journey to learning how to make games.




   We have a slight problem, though. Anywhere we are setting loc manually, we run the risk of not calling Exited()/Entered() properly. Since our areas keep track of who is entering and exiting a location, we need to fix that badly. What we're going to do is add a piece of code that will make our lives a lot easier in this regard. We're going to slightly override Move() to change how it works.

atom/movable
Move(atom/NewLoc,Dir=0,Step_x=0,Step_y=0,forced=0)
if(forced)
var/list/xl, list/el
var/atom/OldLoc = loc

dir = Dir||get_dir(OldLoc,NewLoc)||dir
if(OldLoc)
var/list/ol = locs
if(loc=NewLoc)
step_x = Step_x
step_y = Step_y

var/list/nl = locs
for(var/turf/t in nl)
nl |= t.loc
xl = ol-nl
el = nl-ol
else
el = locs
for(var/turf/t in el)
el[t.loc] = 1

else if(loc=NewLoc)
step_x = Step_x
step_y = Step_y

el = locs
for(var/turf/t in el)
el[t.loc] = 1

if(xl)
for(var/atom/a in xl)
a.Exited(src,NewLoc)
if(el)
for(var/atom/a in el)
a.Entered(src,OldLoc)
return 1
else
return ..()

Del()
if(loc)
Move(null,forced=1)
..()


  Anywhere that you previously would use loc = whatever, you should change that to Move(whatever,forced=1). The above code simply allows the engine to force a movement to succeed and calls the appropriate Entered()/Exited() calls. This is a correction of a very minor oversight on the part of the developers of the engine.