Episode 10 of a series about building an MMO from scratch, with the focus on the engineering around the game. Our world gets its first monsters. The skeleton comes from Quaternius, on Poly Pizza, in the public domain, so unlike the Mixamo characters it goes into our repo as it was downloaded, and the client loads it as it is: scaled by its bounding box, its clips found by name, and a walk made from its run. The server owns the monsters, spawns them, and runs a wander AI for them each tick, with a random generator the tests can seed. Players and monsters get health and levels, which everyone sees, and players get experience, saved in MySQL and sent to their own client alone, in the part of each update that's theirs. The client draws health bars and levels over everyone, and a frame with your own.
Topics: Gameplay
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep010/end.
Transcript
Intro
0:00 Hello and welcome back to episode ten, where our world gets its first monsters, a few skeletons that wander around the board, and our players get health, levels and experience. Let's begin with a quick look at where we're going to be by the end of the episode.
0:15 Two players log in, on a board where three skeletons wander around their homes, each with a level, its name in red, and a health bar. Each player has a health bar too, and the frame in the top left shows your own health, and your experience towards the next level. And a few bots join them, each with a level of its own.
0:35 On the website of the series you can find more information about the project. The code for the project is open source on GitHub, tagged at each step of each episode so that you can follow along.
Last time
0:47 Now, before we get to today's work, let's quickly go over what we did last time. Each world started carrying the time the server sent it, so that the bots could measure how old a world is when it arrives. With netem, we delayed the server's packets and dropped a share of them, the way a bad network does. Without any loss, TCP and UDP were level, as there was nothing to send again. With loss, TCP's worlds wait for each lost packet to be sent again, so its p99 climbed with each step. And at ten percent, it couldn't keep up with two hundred clients. Over UDP, the server sends the world in parts that each fit in a packet, and a lost part leaves its players where they were for a tick. UDP's p99 stayed within a tick or two of no loss. So the client takes its worlds over UDP now, while its input stays on TCP, along with anything else that has to arrive.
Today
1:41 Today, our world gets monsters, and our first one is a skeleton, by Quaternius. We're going to see where it comes from, and why it can go into our repo, unlike the Mixamo characters. The server is going to own the monsters, like it owns the players, and spawn them. And it's going to run a very simple AI for them each tick, which for now is a wander. The client draws them, with the skeleton's own animations, loaded from the file as it was downloaded. Then players and monsters get health and a level, and players get experience, kept in MySQL. Your experience goes to your client alone, as the other players don't need it. And the client shows health bars over everyone, and a frame with your own health and experience. All right, let's get started with where our skeleton comes from.
Our first monster
2:29 The skeleton comes from Poly Pizza, a site that collects free low-poly models. This one is by Quaternius, who has made a lot of free models and animations for games. Under it is its licence, public domain, CC0, which is what lets it go into our repo. It comes with five animations, and the viewer on the page plays each of them. There's a run, and an idle, and the other three are an attack, a death and a spawn. Download gives us the model as glTF, in a single g l b file, like our characters.
3:03 CC0 is Creative Commons' way of putting a work in the public domain. Anyone can copy it, change it and share it, for commercial use too, without asking, unlike Mixamo's files. CC0 doesn't ask for credit either, but Quaternius made it, so our README says so.
3:21 I loaded the file in raylib as it comes, to see what we're working with. It loads, the bones move the mesh, and all five clips play. It has twelve bones, which is very few next to the sixty-five of Mixamo's bots, as a low-poly skeleton has no fingers to move. But it's about five metres tall, and our players are one point eight. Its clips are named like SkeletonArmature, a pipe, then Skeleton underscore Running. raylib keeps the first thirty-one characters of a name, so the run comes out as Skeleton underscore Runni. And there's no walk, so the skeleton has no clip to play when it moves at walking speed. And its arms stay out to the sides, whatever the clip, while its legs and its head move as they should. In the file, the mesh is bound to the bones with the arms held out, but the bones of the arms rest down by its sides. raylib takes the pose the bones rest in to be the pose the mesh was bound in, so it moves the arms from the wrong starting point. We could fix all four with a tool, like the merge tool we wrote for the Mixamo files. Instead, the client handles them as it loads the file, so that the file in our repo stays the one you can download.
Monsters on the server
4:31 As the server owns the monsters, and the clients draw what it sends, the monsters' AI lives in the server, and runs in its tick. For now, it's about as simple as an AI gets, a wander. A monster keeps the place where it spawned, which is its home. It rests for a random time, from two to six seconds. Then it picks a random place within three metres of its home, and walks there, at the players' walking speed. Once it's there, it rests again, for another random time, and the loop starts over. The three metres keep each monster around its own spot on the board, and the random rests keep the monsters from moving in step. In a lot of MMOs, monsters wander like this while nobody's around.
5:14 To pick a random place in the circle, we pick a random angle, theta, and a random distance from home, d. And we turn them into x and z with a sine and a cosine. The obvious distance is the radius times a random number u, from zero to one. That puts half the places within half the radius, and that inner circle is a quarter of the area. So the monster would hang around the middle, and rarely get out to the edge of its circle. Taking the square root of u first spreads the places evenly over the whole circle, as the area inside a distance grows with its square.
5:50 We start in the protocol, where a new message, Monster, carries what a client needs to draw one. It has an id, so that the client can tell the skeletons apart from one world to the next, and a name, to show over its head. Then its x and z, its yaw and its motion, with the Motion enum the players use, as a skeleton stands, walks and runs too. The World message gets repeated Monster monsters, as field six, next to the players. We keep them apart from the players, as a player's id is its account's id, and a monster isn't an account. If they shared a list, player one and monster one would clash. In the world's header, we include random, for the monsters' random choices. A Monster struct goes next to Player, with a name, an x and a z, a yaw and a motion, like a player. Then home x and home z, which is where it spawned, and target x and target z, where it's walking to at the moment. And rest is how much longer it rests for, in nanoseconds like the tick's time, so that each tick takes its time off the rest with no conversion. Then the World gets a constructor, which takes a seed for the monsters' random choices. So a test can pick a seed, and know what its monsters are going to do, while the server picks a random one. It's marked explicit, like our other constructors with a single argument, and its default is the generator's default seed. So the tests that don't use the monsters still make a World with nothing in the brackets. Spawn puts a monster into the world, where it stands, and returns its id, and monsters hands them out by id, like players does. Privately, the world keeps the monsters in a map by id, next to the players'. And the next monster's id starts at one, as zero is going to mean no monster. And random, an m t nineteen nine three seven, which is the Mersenne Twister we used for the bots in episode four. For a given seed, it gives its numbers in a fixed order on any platform, which the tests rely on. In the source file, we include monsters dot h, where the AI lives, which we're going to write next. The constructor seeds random, and spawn sets the monster's home to where it stands, so whoever spawns a monster decides where its patch of the board is. Then spawn gives each monster its first rest, from random rest. Over it, we write why: a rest of its own, so that the monsters don't all set off together. Then spawn takes the next id, puts the monster into the map, and returns the id. And at the end of step, after the players have moved, we go through the monsters with a structured binding. Each one wanders, for the tick's time, with the world's random generator.
8:30 The AI goes in a file of its own, monsters dot h. The world's job is to move things and keep track of them, and the AI's is the choices a monster makes. So keeping the two apart means the world doesn't grow with each new kind of monster. We include chrono and random, for the rest times and the generator the world hands in, and the world's header, for the Monster struct that the AI works on. K wander radius is how far from home a monster goes, three metres. K shortest rest and k longest rest are two and six seconds, written in milliseconds, so that a rest can fall anywhere between them, and not on whole seconds. Skeleton makes a skeleton, standing where we want it to spawn, so that the server and the tests make their skeletons from one place. Random rest picks how long to rest for, and wander moves one of the monsters on by some time. It takes the generator by reference, as a generator changes with each number it gives, and it's the world's that has to move on, not a copy of it. All three are plain functions in our namespace, as none of them keeps anything between calls, so they don't need a class around them. In the source file, we include algorithm and cmath, for clamp, the sine, the cosine and the square root. And numbers, new in C++20, the standard library's header for constants such as pi. Pick target goes in the unnamed namespace, and it's the circle we just saw. Over it goes the reason: the square root spreads the places evenly, not bunched up around home. A uniform real distribution turns the generator's numbers into floats, here from zero to one, with any value as likely as any other. The angle goes from zero to two pi, and the distance is the radius times the square root of the next number, like in the formula. Then target x and target z put the target at that angle and distance from home. Sine for x and cosine for z matches our yaw, where zero faces along z. Both are clamped to the board's edge, so that a skeleton close to it doesn't pick a place off the board. Near the edge, that piles some of the targets up along it, which is fine for a wander. Skeleton returns a Monster called Skeleton, at x and z, with designated initialisers, like before. Random rest uses a uniform int distribution, from the shortest rest to the longest, in whole milliseconds, and turns the number it picks back into a duration. Then wander, which is a monster's turn in a tick. If the monster is idle, its rest goes down by the tick's time. While there's rest left, it stays where it is, and once the rest is over, it picks a target, and starts walking. Then comes the walk, which is where a monster that was already walking starts its turn. D x and d z are the way to the target, and distance is how far that is, with hypot. Step is how far it walks in this tick, at the players' walking speed, as that's what the walk animation is made for. If the target is closer than a step, the monster stops on it, instead of stepping past it, goes idle, and gets a new rest. Otherwise, it moves a step towards the target, along d x and d z divided by the distance, which is the direction with a length of one. And its yaw is a tan two of d x and d z, like a player's.
11:41 Now the server has to spawn the monsters, and send them out. In server dot c c, we include random, for a seed, and then monsters dot h, for the skeleton. K skeletons, in the unnamed namespace, are the three spots where skeletons spawn, as pairs of x and z. Each one is a few metres out from the middle of the board, so that the middle, where new players start, stays clear. The world is now made with a seed from random device, which we used in episode five. So the monsters wander differently each time we start the server, while the tests pick seeds of their own. Then, in start, once the sockets are open, a skeleton spawns at each spot, with a structured binding on each pair's x and z. In broadcast, after the players, each monster goes into the message, with its id, its name, its position, its yaw and its motion. And to protocol turns the motion into the protocol's, like it does for the players. The comment over the serialising now says the players and the monsters go to everyone alike, so we serialise them once. And all the sessions share the bytes until each has written them. So three monsters cost a few dozen bytes in each world, and no extra work for each client. In the server's CMakeLists, monsters dot c c goes into the server's core library, next to the world. So the tests can call wander on a monster directly, without starting a server. And monsters test dot c c joins the tests' sources, next to the world's tests. The monsters get a test file of their own, next to the code, monsters test dot c c. We include monsters dot h, then chrono, for the tick and the rest, cmath, for hypot, and random, for the generator, with GoogleTest last. K tick is fifty milliseconds, the length of the server's tick, so that the tests move a monster on in the steps the server uses. Rests then walks makes a generator with a seed of seven, and a skeleton with a second of rest. Then it lets the skeleton wander for nineteen ticks, and checks that it's still idle after them. The twentieth tick ends the second of rest, so the skeleton walks. That pins the moment a rest ends, so that a monster doesn't set off a tick early, or a tick late. Stays near home and on the board is the one I care about most. It puts a skeleton's home in the corner of the board, where both of its limits matter: the radius around home, and the board's edge. Then it runs ten minutes of ticks, twelve thousand of them. After each one, it checks that the skeleton is within the radius of home, with a thousandth of a metre to spare for rounding. And it checks that the skeleton is still on the board. Assert le, less or equal, stops the test at the first failure, so that a bug in the AI doesn't print twelve thousand of them. In the world's tests, we include monsters dot h too, for the skeleton. Spawns monsters with ids of their own makes a world with no players in it, and spawns a skeleton at two and minus one, and another one at minus three and zero. It expects their ids to be one and two, and the first one's home to be where it spawned. And the protocol's round trip test now carries a monster, with its name, to show that the new field makes it through serialising and parsing.
14:55 Over UDP, the server sends the world in parts, and the client puts them back together, so the monsters have to go through that too. In parts dot h, Parts gets a map of monsters by id, next to the players', and seen monsters, like seen does for the players. So a lost part leaves its monsters where they were, while a monster missing from the whole tick drops out. In parts dot c c, the comment over k room now reads: room for the fields besides the players and the monsters, sent, part, parts, and the client's own you. The number stays at thirty-two, as the monsters go through the parts' size check, with the players. Then comes split, where the size check used to sit inside the loop over the players. Now the players and the monsters both need it, so it moves into part for, a lambda inside split. It captures split's parts and size by reference, so that it changes them, not copies of them. It takes how many bytes a player or a monster needs, and returns a reference to the part they go into. Empty says whether the last part has no players and no monsters in it yet. The rule is episode nine's: a part that isn't empty starts a new one when the bytes wouldn't fit, and an empty part takes anything, so nobody is left out. Then the size grows by the bytes, and part for returns the part. Each player then goes into the part that part for picks, asking for its byte size long, plus three. The comment over the loop says what the three is: a byte for the field's number, and up to two for its size. After the players, each monster goes through part for, so the two share one size check, and a part can carry both. In add, a new tick clears seen monsters too, like it clears seen for the players. Each monster in a part goes into the map by its id, and into seen monsters. Once the tick's parts have all arrived, erase if drops the monsters that none of them had, like it does for the players. And world puts the monsters back into the world it returns. In the parts' tests, leaves the players of a lost part where they were now puts three monsters into the first tick's world. The second tick has no monsters, and its first part is lost. As that tick's parts don't all arrive, nothing is dropped, and at the end, all three monsters are still there.
17:14 Once it's built, we run the world's tests and the monsters', and all nine of them pass, with the three new ones among them. And the parts' tests pass too, with monsters in the world.
Drawing the monsters
17:26 The skeleton goes into a new folder, assets slash monsters, as it was downloaded. And a README next to it says who made it, where it's from, its licence, and how the client loads it. In the client's CMakeLists, the custom target that copied the characters next to the client becomes nanommo client assets. It copies assets slash monsters too, into a monsters folder, and the client and its tests depend on it under its new name. In the characters header, monsters folder gives the folder next to the client with the monsters in it, like characters folder does for the players. So far, the Characters class has loaded our two bots, from a list of its own. Now it takes the files to load, as a vector of strings, and how tall to make them, in metres, so that one class loads the bots and the skeleton alike. Each Character gets speed, how fast each motion's clip plays, one by default, and scale, so that its model comes out as tall as we asked. In the source file, we include cgltf, a small glTF reader by Johannes Kuhlmann, which raylib loads our models with. raylib builds it into its library already, so we include the header from raylib's external folder, as a second copy of cgltf would clash with raylib's when we link. Then raymath, which is raylib's maths header, for inverting, multiplying and splitting up its matrices. And algorithm, for transform, and array, for a matrix as sixteen floats. Then cctype, the C header for single characters, for to lower. And string view, for the word we look for, and vector, for the list of files we're given. Cstring goes, with its string compare, as we no longer match names letter for letter, and so does the k files list. Has word says whether a clip's name has a word in it, whatever its case. We explain why in a comment: raylib cuts clip names at thirty-one characters, so the skeleton's run ends in Runni, which still has the word run in it. Has word takes the name by value, as a copy it can change, and transform, from ranges, lowers each letter of that copy in place. Each character goes through as an unsigned char first, as to lower is undefined for negative values, and a plain char can be negative. Then find looks for the word, and n pos is what it returns when the word isn't there. From gltf turns a matrix from the file, as sixteen floats, into raylib's Matrix. The floats go in a different order, as glTF stores a matrix a column at a time, and raylib's Matrix lists its fields a row at a time. The braces fill the Matrix's fields in the order they're declared, so we pick the floats out a row at a time. The first, the fifth, the ninth and the thirteenth float make up the top row. Read bind pose puts the skeleton's arms right, with the inverse bind matrices from episode seven. Each bone's matrix is its pose in this frame, times its inverse bind matrix, which takes a vertex from where the mesh was bound into the bone's own space. In most files, and in our bots, the bones rest where the mesh was bound, so working them out from the rest pose has been fine until now. Over the function, we write down what goes wrong for the skeleton. raylib takes the pose the bones rest in as the one the mesh was bound in, but the skeleton's arms rest about seventy degrees away from it. glTF keeps the bind pose as each bone's inverse bind matrix, so we read it from those. raylib's code on GitHub has started reading them after the six point oh release we use, but it doesn't move them with the mesh's node, which our skeleton needs. cgltf parse file reads the file's description of its nodes, meshes and skins. If it can't, we return, and the model keeps raylib's bind pose. cgltf hands us the file's nodes as a plain C array and its count, so we go through them by index. We look for the mesh with a skin, as a skin holds the bones' inverse bind matrices. The loop stops at the first one, as raylib loads a single skin for a model, and each of our files has one. Load buffers reads the binary part of the file, where the matrices' floats are, once we know there are matrices to read. Transform world gives the matrix that puts the mesh's node in the world, through all of its parents. For our skeleton, it scales the mesh up a hundred times, and turns it a quarter turn to stand it up. Then a loop goes through the bones, and above it, we say why the node matters. raylib moves the mesh to where its node puts it in the world as it loads it, so the bind pose goes there too. Inverse is declared inside the loop, so that each bone starts from zeros, and one bone's floats can't carry over to the next. Accessor read float reads the bone's inverse bind matrix into those sixteen floats. If a file has fewer matrices than bones, the read fails, and that bone keeps raylib's pose. We invert the matrix, which gives the bone's bind matrix, and multiply that by the node's world matrix. Matrix decompose splits the result into a translation, a rotation and a scale, which is how raylib keeps each bone's bind pose. Pose is a reference into the model's bind pose, so matrix decompose writes straight into the model. And cgltf free gives back the memory cgltf allocated, whether or not we got as far as the bones. Monsters folder works like characters folder, and returns the monsters folder next to the client. The constructor takes the files and the height now, next to the folder and the shader, and goes through the files it was given. Right after load model, read bind pose sets the model's bind pose from the file, before any clip poses its bones. Each clip is found by its word, with has word, and as our bots' clips are called idle, walk and run, they still match. Then comes the walk, which the skeleton doesn't have, so the constructor makes one. A structured binding names the three places in clip for idle, walk and run, as references into the array, so that changing walk changes the array. If there's no walk, but there's a run, the walk points at the run, and the walk's speed is nought point four. The comment over it reads: without a walk, a character walks with its run, played slower, and the skeleton's feet keep up with our walk at nought point four. I measured how fast the skeleton's feet go backwards along the ground while they touch it, in its run, and it's about four and a half metres a second. That's a quick, cartoon run, well above our own running speed of three point one. At nought point four of that, the feet move at about one point eight metres a second, our walking speed, so they don't slide over the ground. If a clip is still missing, the warning and the clean-up are as before. Once the shader is set, get model bounding box measures each model, in the pose it was made in, which for the skeleton is standing up. And the scale is the height we want over the height of the box, about one for our bots. For the skeleton, it's one point six over about five, so roughly a third. In draw, index is a small lambda that turns a motion into its place in the arrays. A motion this client doesn't know, say from a newer server, becomes idle, so that we don't read past the end of an array. From and to are the places of the clip we blend from, and the clip we blend to. From clip and to clip look those clips up in the character's clips, through clip for. Each gets a name, as it's needed twice, once for the pose and once for its frame. Then update model animation E X poses the bones from the two clips, like in episode seven, and what changes is each clip's frame. The frame now comes from the clip's time, times its speed, which is how the skeleton's walk plays slower. And draw model E X scales the model by the character's scale, on all three axes alike.
25:16 The animator smooths each player between the server's ticks, and blends its clips, and the monsters need that too. In its header, a Figure is a player or a monster now, and monster says which. So the renderer picks the model to draw, and the nameplates the way to show it. Until now, the animator kept one map of figures, by id, from one frame to the next. Now the players get a map of their own, as the monsters need one too. Its comment says why: players and monsters each have ids of their own, so they're kept apart. Then, under the players' map, comes the monsters' map, by the monsters' own ids. In the source file, update did all the work for each player. That work moves into a function of its own, so that the monsters can use it too. Its first line makes it a template, a function the compiler writes for us once for each type we call it with. Here, those types are the protocol's Player and its Monster. They aren't related types, but both have an id, a name, a position, a yaw and a motion, under those names, so the code compiles for either. We could have given them a shared base, or copied the function, while the template keeps a single copy of the code, without changing the protocol. Then the function, follow, which takes the figures we had, a unit from the message, and the time since the last frame. Unit is the name we give the type, so inside the function, a player or a monster is a unit. The body is the code from before, with unit where it said player. Target is where the server says the unit is, which the figure moves towards. If we had no figure for this id, we make a new one, standing where the unit stands, already in its motion. So a new figure doesn't slide in from the middle of the board. The figure gets the unit's id and name, and then it moves the clips' times on. When the motion changes, the clip we were heading to becomes the one we blend from, and keeps its time. The unit's motion becomes the clip we blend to, from its start, and the blend starts again from zero. Then the blend moves on by the time over the blend time, up to one, which is all the way into the new clip. Then the figure moves a share of the way to where the server says it is, and the follow rate sets how quickly the gap closes, whatever the frame rate. And it turns towards the unit's yaw, by no more than the turn rate allows in this frame. As follow isn't a member of the animator, the blend time, the follow rate and the turn rate are the Animator's, by name. Update now builds two new maps, one for the players and one for the monsters, next to the list of figures it returns. Each player goes through follow, with the players' map, is marked if it's you, and goes into the new players' map. Then it's moved into the list, as we're done with this copy, and a move hands its name over without copying it. Each monster goes through follow, with the monsters' map, gets monster set to true, and goes into the new monsters' map, and into the list too. So the renderer gets one list of figures, and draws them in one loop. Then the new map of players replaces the old one, with a comment over it: players who left, and monsters that went, drop out here. The monsters' map is replaced too, as a figure that wasn't in this world doesn't make it into the new maps. The renderer's header gets a second Characters, monsters, next to the characters. In the constructor, the characters load x bot and y bot, at one point eight metres, which is how tall they already are. And monsters loads the skeleton at one point six, a bit shorter than our players. In draw, looks is the monsters or the characters, depending on the figure. A monster keeps its model's own colours, so its tint is white, while a player gets its own colour, like before.
29:02 Let's see the skeletons on the board, now that the client can draw them. The login server and the game server are at the bottom, and the client logs in. There are three skeletons, each one wandering around its own spot. Their arms hang down by their sides, as the client reads the bind pose from the file. Each one rests, then walks off somewhere close, with its run played slowly, and rests again, at times of its own. I walk over towards the one at the top, and it doesn't react to me at all, as its AI doesn't look at players.
29:36 Now that we've seen them on the board, the skeleton and the animator get tests of their own. In the characters' tests, loading both bots passes x bot and y bot now, at one point eight metres. Then a new test, loads the skeleton with a walk made from its run. Like the bots' test, it creates a hidden window, as loading a model needs OpenGL, and asserts that the window is OK before it goes on. Then it makes a shader, and loads the skeleton from the monsters folder, at one point six metres. And it expects to end up with one character, which is the skeleton. A character without all its clips is left out, so that one character means the clips were found by their words, and the run stands in for the walk. The skeleton is in git, so unlike the bots' tests, this one runs in CI too, without anyone downloading anything. Draws the skeleton with its arms down pins the bind pose we read. Like the test before it, it opens a hidden window first, as drawing needs OpenGL too. Then it makes an offscreen target, like the renderer's tests do, at two hundred and fifty-six pixels a side, so that a forearm is a few pixels across. The renderer gets the blue background our tests use, and a single figure, a monster standing in the middle in its idle, and draws them into the target. Pixel is a small lambda that finds where a point in the world lands on the screen, with get world to screen E X, and reads the pixel there. The skull, at one point five metres, isn't the background's colour, which shows that the skeleton is drawn. And the last check looks where its forearm would be, held out to the side at shoulder height, as the comment says. With the bind pose from the file, that pixel is the background, while with raylib's own, the forearm covers it. And the animator gets a new test, keeps players and monsters apart, for the bug we'd get if the two shared a map. It starts from a world with a player with an id of one, from the helper the other tests use. And it adds a monster called Skeleton, also with an id of one. One update later, it asserts that there are two figures, so that the test stops there if it gets fewer, before it reads past the end of the list. The first one is a player, and the second one is a monster, with the skeleton's name.
31:52 Then we run the client's tests, and all eleven pass, with the three new ones among them.
Health, levels and experience
32:01 Levels come first, and each level takes a hundred more experience than the one before. So it's a hundred to get to level two, two hundred more to level three, and so on. A player has a hundred health at level one, and twenty more with each level after it. A skeleton is level one, with fifty health, which is half of a new player's. For a player, we save its experience, all of it, in the players table, next to where it stands. Its level comes from that, so the server works it out instead of saving it twice. Health isn't saved yet, so a player joins at full health. Each player's and each monster's level and health go to everyone, as they're drawn over their heads. A player's experience goes to that player alone, as nobody else needs it to draw anything. In episode eight, each client started getting a small message of its own after the shared world, with its id in it. That's where the experience goes: how far you are into your level, and how much the next one takes. The server does the maths, and the client draws the bar, which is the thin client again, with the rules on the server.
33:06 The rules for levels go in a file of their own, levels dot h, so that the server, the world and the tests all work from one set of numbers. We include cstdint, for the fixed-size integers that hold the experience. A Level struct says where a player stands, with its number first, counting from one. Then the experience it has into that level, which is how full its bar is. And next, how much experience the next level takes, which is where the bar ends. Level for works all of that out from the experience a player has in total, the one number we keep. And max health gives the most health a player can have at a level. Both are plain functions, so a test can hand them a number and check what comes back. In the source file, we include its own header, and nothing else, as it's all arithmetic. Level for starts at level one, with all the experience still to place, and a hundred to the next level. While there's enough for the next level, it takes that much off, goes up a level, and the next level takes a hundred times the new level's number. We could have solved it with a formula instead, but a loop is fine here, as the levels get further apart. A million experience is level a hundred and forty-one, and that many turns of a loop take next to no time. And the loop reads like the rule, which makes it easy to check. Max health is a hundred, plus twenty for each level after the first. In the world's header, a player gets experience, all of it, unsigned, as it can't go below zero. And health, which is signed, so that a hit can be worked out before we clamp it. A monster gets its level, its health, and max health, which is part of what kind of monster it is, and not worked out from its level, like a player's. In the world's source file, we include levels dot h, for max health. Join sets a player's health to the max health for its level, and spawn sets a monster's health to its max health, so both start at full health. And in monsters dot c c, the skeleton is level one, with fifty health, so that a player at level one can take one on.
35:11 In the protocol, a player gets its level, its health, and max health, the most it can have, as fields seven, eight and nine. They're all unsigned, as none of them goes below zero, and protobuf writes small numbers in a byte or two, so they cost little in each world. Monster gets them too, under those numbers, as a monster's level and health are shown over its head. And World gets two fields for your player alone, experience and next level, as fields seven and eight, which are the two numbers your bar needs. In the server, we include levels dot h, so that broadcast can work out each level. In broadcast, each player's level comes from its experience, and goes into the message with its health and its max health. Each monster's level, health and max health go in as they are. Then the part that's yours goes in the small message that follows the shared world for each client. Level for gives us the level, and you gets its experience and next level. That's a few bytes for each client, and the shared part stays shared, serialised once. Over UDP, that small message goes with each part of the world. So in parts dot h, Parts keeps experience and next level, next to you, from the last part that came in. In parts dot c c, k room grows from thirty-two to forty-two bytes for those two numbers. Protobuf writes each new number in up to five bytes, plus a byte for its field number, and k room already had a few bytes to spare. So the comment over k room adds the experience and the next level to the fields it makes room for. Add takes them from each part, like you, and world puts them into the world it returns. The experience has to outlast a log out, so in the schema, the players table gets a new column, experience. It's an unsigned int, not null, with a default of zero for a new row. In players dot c c, load's select adds coalesce of experience and zero, like x and z, as an account that hasn't played yet has no row in the players table. So a new player starts with no experience, like it starts in the middle of the board. MySQL's C API hands us each value as text, so load parses the numbers it gets back. The experience comes from the fourth column, with Abseil's simple a to i, next to the position's simple a to f. A value that isn't a number, or doesn't fit in thirty-two bits, ends up in the Internal error that load already returns. Save inserts the experience as well, next to x and z. On duplicate key update sets it from saved, the row we tried to insert, like x and z, so that saving a player twice updates its row. In the server's CMakeLists, levels dot c c goes into the core library, next to the monsters, where the world, the broadcast and the tests can all use it. And levels test dot c c joins the tests, after the monsters' one. Both lists get wrapped again, to keep the lines short, which is why more lines change than the two names we added. The rules get a test file of their own, which includes their header, and then GoogleTest. Each level takes a hundred more than the last checks the rule at its edges, and in the middle, as the edges are where a loop like ours goes wrong by one. No experience at all is level one, which is where a new player starts. Ninety-nine is level one too, a point short of level two, so a hundred is where level two starts. Then four hundred and fifty is a hundred to get to level two and two hundred more to level three. So it expects level three, a hundred and fifty in, with three hundred to the next one. In the world's tests, joins players at full health gives a player a hundred experience, which is level two, and expects level two's hundred and twenty health. The players table's test saves forty experience with a position and loads it back, which shows that the column, the insert and the select line up. In the protocol's test of two messages read back to back, the second message is the one with you in it. And it now carries forty experience, which comes out of the merged world, like you does. And in the parts' tests, the small message that goes with each part gets experience and next level at their largest, four billion. Each datagram still fits in its twelve hundred bytes, which is what k room's forty-two bytes are for.
39:23 MySQL runs our schema when it starts on an empty volume, and ours isn't empty, so the new column wouldn't appear. Our accounts are test accounts we can throw away, so docker compose down dash v deletes the volume, and the accounts in it. Once there are players to keep, that won't do, and the database will need migrations, which change a database that has data in it. Then the server's tests run in Docker, next to MySQL and Redis, from a fresh volume with the new schema. They all pass, along with the players table's test of the new column.
On screen
40:00 On to the client, which is going to show all this. In the animator's header, a Figure gets its level, its health and its max health, so that whatever draws the figures has them at hand. In follow, they're copied from the message, and as both messages have them now, the template gives players and monsters the new fields in one place. The static casts turn protobuf's unsigned numbers into the ints the figure keeps. The nameplates get the most work, as until now they showed just a name, and now each one shows a health bar, with the level and the name over it. In nameplates dot c c, we include algorithm, for clamp, and string, for to string, to turn the level into text. In the unnamed namespace, the colours go in as constants, so that they have names, and live in one place. The first, k shadow, goes behind the text, in a dark, slightly see-through black. K level is grey, so the level sits back next to the name. K monster name is a light red, which is how a lot of MMOs mark something you can fight. Then come green for the players' health bars, and red for the monsters'. K bar is the health bar's size, sixty-four points by six, which is about the width of a short name. Draw shadowed text puts a dark copy of a string under it, like our names since episode six, and it's a function now, for the two strings over each figure. It gets the font from ImGui, and draws the text in the shadow's colour, a point down and to the right. And over it goes the text in its own colour, so it reads over light and dark tiles alike. In draw nameplates, above is how high over the feet the plate sits. It's lower for monsters, as the skeleton is shorter, and they have no marker over them to clear. Get world to screen turns that point into a point on the screen, through the game camera. The health bar sits on that point, and bar min and bar max are its corners, centred on it. Share is how much of the bar is full: the health over the max health, clamped from zero to one. It's zero when there's no max health, say from an older server, so that we don't divide by zero. We draw the dark strip first, then the coloured part over it, from the left, as wide as the share. So the missing health shows up dark, and you can see at a glance how much is gone. Over the bar go the level, then the name, side by side. The level is a string, with a space after it. Calc text size A measures how wide each one is in this font, so that together they can be centred over the bar. Left is where the level starts, and top puts them both two points over the bar. Then we draw the level, in grey, and the name next to it, white for players and red for monsters, so you can tell at a glance what you can fight. Our own health and experience go in the top left corner, the way most MMOs show them, in what's often called a player frame. It's a small ImGui window, drawn apart from the nameplates, so it goes in a file of its own, player frame dot h, with its header guard. We include vector, for the figures, and then the animator's header, for Figure. And nanommo dot p b dot h, for the protocol's World, as the experience isn't in the figures. Then, in our namespace, a free function, like draw nameplates, as the frame has no state of its own. It takes the figures and the world, and it's called draw player frame. In the source file, player frame dot c c, we include imgui, as the frame is an ImGui window. Then algorithm, for find if and clamp, string, for the bars' labels, and vector, for the figures. And icons font awesome six dot h, from rlImGui, for a heart and a star on the bars. The bars' colours are ImGui's, as four floats: green for health, and purple for experience. Draw bar draws one bar, filled to the value over the most, in a colour, with its name and the numbers on it, such as health, eighty out of a hundred. The share is clamped like the nameplates', and zero when there's no most. The label is built up as a string, as ImGui takes text to draw on the bar. Push style color sets the colour ImGui fills progress bars with. And pop style color puts the old one back, so that the colour doesn't leak into the rest of the interface. In between, progress bar draws the bar, two hundred and twenty points wide, with the label on it. Then draw player frame, whose first job is to look for us among the figures. Find if, from ranges, goes through the list for the first one that's ours. We hand it a pointer to the member you, and it checks that bool on each figure in turn. Until we're in the world, there isn't one, so find if returns the end of the list, and the frame isn't drawn. Set next window pos puts the frame under the menu bar, ten points in from the corner. Begin starts the window, which is called You, and ImGui uses that name to tell its windows apart. We pass no close button, so the frame stays on screen while we play. The window's flags take away its title bar and its borders, and let it resize to fit what's in it. And they stop it from moving, or taking the keyboard from the game, so that W A S D still moves us. Inside it, our name, and on that line, our level, greyed out. Then the health bar, from the figure, whose name starts with a heart. Like the menu's labels in episode two, the icon's macro and the text next to it make one string. And the experience bar, with a star, and the experience and the next level from the world. And end closes the window, as each begin in ImGui needs its end. In main, we include player frame dot h, and after the nameplates, we draw the player frame, with the renderer's figures and the connection's world. The client's CMakeLists adds player frame dot c c to the client's core library, where the tests can reach it too.
Together
45:54 Let's play it together now, and see what each player sees of the others. The login server and the game server are at the bottom, and two clients log in, as alice and as maria. The two windows match on where each skeleton is and what it's doing, as both draw what the server sends. Each skeleton has its level, its name in red, and a full health bar. In the top left of each window is that player's own frame: level one, a hundred health out of a hundred, and no experience yet. Alice walks up to a skeleton, which carries on wandering past her. Maria walks down and to the right, and her figure moves in both windows. Next to the servers, nanommo bot starts ten bots, over four seconds, and they walk and run between the skeletons, each with its level and health bar.
Docs
46:41 Last, the README, which says what the client shows now: levels, names and health over everyone, the skeletons, and your own frame. A new section has the skeleton's source and licence, and the numbers behind the monsters, the levels and the health, for anyone who wants to tune them. And it has a gotcha for anyone who played before today: a MySQL volume from then has no experience column, as the schema runs on an empty volume. So docker compose down dash v starts it again, without the accounts.
Wrap-up
47:11 So, today our first monster came from Poly Pizza: Quaternius's skeleton, in the public domain, so it's in our repo as it was downloaded. The client loads it as it is, scaled down to one point six metres, with its clips found by name, and a walk made from its run, played slower. The server owns the monsters, and runs their AI each tick, with a random generator that the tests can seed. Players and monsters have health and levels, which everyone sees, while each player's experience is saved in MySQL, and goes to its own client alone. The client draws health bars and levels over everyone, and a frame with your own.
Playing it
48:53 Let me know in the comments what you'd like to see next, and if you want to follow along, subscribe. I hope you find this useful, and thanks for watching.