Episode 23 of a series about building an MMO from scratch, with the focus on the engineering around the game. Players get a chat, where a line goes to everyone in their world. The line travels to the server with the player's input, and the server sends what was said in each tick to each player in a single message, over TCP, even to the clients that get the world over UDP, as a line that's lost is gone. The client gets a chat window in ImGui, where typing a line first walks you around and swings your sword, until the game's keys give way to the text field. Then the server cleans each line, as anyone can write a client, and a token bucket for each player keeps anyone from flooding the chat. Last, our bots talk now and then, and the dashboard counts the lines.
Topics: Networking, Gameplay
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep023/end.
Transcript
Intro
0:00 Hello and welcome back to episode twenty-three, where we're going to let our players talk to each other, with a chat for everyone in their world. Let's begin with a quick look at where we're going to be by the end of the episode.
0:12 The chat window in the bottom left corner shows what our bots say as they play, a line now and then, under their names. I press Enter, type a line, and press Enter again, and everyone in the world gets it, under my name.
0:29 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:41 Now, before we get to today's work, let's quickly go over what we did last time. We put the look of the island in one place, its sky, its fog and its light, as data that the shaders read each frame, and gave the Meadows a lower, warmer sun. Each world got a second land, the Ashlands, which the world server runs in its tick loop next to the Meadows, with a gate between them that puts you out past the other land's gate. A migration keeps the land you're in, and our metrics and our bots know about lands, so that the dashboard shows how the players spread over them.
Today
1:15 Today, we're going to add a chat, so that a line one player types reaches everyone in their world, which the server sends out over TCP, once a tick, even to the clients that get the world over UDP. The client gets a chat window, where Enter starts a line, and sends it. Then the server cleans what each player says before anyone else sees it, and puts a limit on how fast a player can talk, so that a single player can't flood everyone's chat. Last, our bots are going to say something now and then, and the dashboard is going to count the lines. All right, let's get started with what chat in an MMO usually looks like.
Chat in an MMO
1:51 Most MMOs have a few kinds of chat, which they call channels, and say is for the players around you, about as far as you can see. A world channel, or a trade channel, reaches everyone in your world, wherever they are, while a party or a guild has a channel for its members, and a whisper goes to a single player, by name. We're going to start with world chat, as it's the simplest on the server: a line goes to each player connected to the world, in both lands, so there's no working out who should get it. World chat is also the one that costs the most, as each line goes to each player, and we're going to come back to that later on.
2:26 Let's follow a line from one end to the other, starting where alice types it and presses Enter. Her client sends it to the server with her input, the message that already carries the keys she holds, in a new field called say. Saying something is an action, like walking or swinging the sword, so it fits in the input, which goes over TCP. The server collects what's said during a tick, and when the tick ends, after the worlds are on their way, it sends the tick's lines to each player, in one message. Sending each line as it arrives would mean a write to each player for each line, while waiting for the end of the tick costs fifty milliseconds at most, which nobody notices in a chat. Most clients get the world over UDP, where a world that's lost is replaced by the next one, twenty times a second. A line that's lost is gone, though, so chat goes over the TCP connection that each client has already, which re-sends what's lost, and keeps the order. Each client keeps the last hundred lines, and shows them, while the server prints each line in its log.
The protocol and the server
3:27 We start in the protocol, in our proto file, where the input gets a fifth field, say, a string with what the player says. It's empty almost all the time, and proto three leaves an empty string out of the message, so the inputs we send while walking get no bigger. Chat is a line that the server sends out, with the name of the player who said it, and the text. The server fills in the name, from the player it loaded at login, so that a client can't say something under another player's name. And the world gets the lines said, as a repeated field, chat. Over TCP, a message from the server is a world, so a message with chat in it is a world too, with nothing else in it, and the client is going to tell the two apart by that. Then a header in the protocol library, chat dot h, for the longest line that a player can say, as the client and the server are both going to need it. It has cstddef, for size t, and k max chat length is two hundred, in bytes, which is plenty for a line of chat, and fits in a few rows of the window we're going to draw. Its comment says what that means in characters: a character takes one to four bytes in UTF-8, so that's at least fifty characters.
4:39 In server dot h, we include our generated protocol header, as the server is going to keep a protobuf message of its own. Say takes the id of the player who said something, and the text, and send chat sends the tick's lines out. Chat is a world that collects what the players said since the last tick, as its comment says, so that sending it is a single call to serialize. In server dot c c, where a message from a player is parsed as their input, and goes to their land, an input with something in say goes to say too. Say adds a line to the tick's chat, with the player's name, from the land they're in, and the text as it came. And it prints the line in the server's log, as the name, a colon and the text, so that the log keeps a record of what was said. In the tick, send chat comes after broadcast, once the worlds are on their way. Send chat returns straight away when nobody said anything, which is most ticks. Otherwise, it serializes the lines once, into a shared pointer, like we do with the worlds. The comment says where they go: each line has to arrive, so it goes over TCP, even to a client that gets the world over UDP, and everyone in the world hears it, in both lands. Each player's session gets the pointer, so that a thousand players share one copy of the bytes, with nothing of their own after it, where a world has each player's own part. Bytes sent counts it, with the four bytes of its size in front, and clear empties the chat for the next tick.
6:05 On the client, the connection keeps the lines it hears. In connection dot h, deque comes in, like for the session's outbox in episode three, as we add the newest lines at the back, and let the oldest go from the front. Chat returns the lines for the window to draw, as a const reference, and its comment says they're the newest lines said in the world, oldest first. Hear takes a world with chat in it, and chat is the deque of lines. In connection dot c c, cstddef, for size t, and k chat lines is a hundred, the most lines we keep. Where we read a message over TCP, the world it parses into goes to receive as before, unless it has chat in it, which goes to hear instead. The comment says why that's enough: chat comes on its own, with nothing of the world in it. Receive would take a message with chat for a world with nobody in it, and no time on it. Hear appends the lines at the back, with insert, which takes the repeated field's begin and end, like any container's. Then it drops lines from the front until we're down to a hundred, so that a long evening of chat doesn't grow the client's memory line by line. Our bots read the messages from the server as worlds too, and take their land from each one, which is how a bot notices that it went through a gate. So receive skips a message with chat, as chat has no land in it, which would read as the Meadows, since a field that isn't there reads as zero. Without that, a bot in the Ashlands would think it was back home, each time someone said something.
The chat window
7:37 Now the window, in a new header, chat window dot h, with array, for the text that the player types, deque, for the connection's lines, optional and string, and chat dot h, for the longest line. Chat window is a class, as it keeps what's being typed from one frame to the next, and the game calls its build function on each frame, like the character screen's. Build's comment says that it draws the lines said in the world, over a field to type in, which Enter goes to, and returns what the player says, once Enter sends it. That's an optional string, empty on all the frames where the player sends nothing. Typing is a buffer of chars for ImGui's text field, which writes into memory that we give it, like the name and the password on our login screen. It has one more char than the longest line, one more for the zero at the end, which ends a string in C. In chat window dot c c, imgui, cfloat for the smallest float, and the standard headers we use. K name is a light blue, for the names in front of the lines. In build, the window goes in the bottom left corner, ten pixels in from the edges. A pivot of zero and one puts the window's bottom left corner on that point, so that it stays in the corner whatever the screen's size. It's four hundred and twenty pixels wide and two hundred high, and its background is forty percent opaque, so the game shows through it. Like the player frame, the window has no title bar, it can't be moved or resized, and it doesn't take the focus when it appears. It takes no keyboard navigation either, and the comment says why: Space presses the widget that ImGui's keyboard navigation is on, which would open the field again after each line. Once we send a line, the navigation would stay on the field, and Space, which swings our sword, would put us back in the chat. The lines go in a child window, which scrolls on its own, and takes the room above the field. A negative height means up to that far from the bottom, and the field takes the height of a frame, with the spacing under it. For each line, the name in blue, with a colon, and after it on that line, the text, wrapped at the window's edge. Both go through a format of percent s, so that a player who types a percent sign doesn't get read as a format. Then the rule in the comment: follow the newest line, unless the player has scrolled up to read. While we're at the bottom, set scroll here y, with one, scrolls to the end, past the lines that came in, and once you scroll up, it leaves you where you are. Enter, when we're not typing already, puts the keyboard's focus on the next widget, which is the field, so that Enter starts a line from anywhere in the game. The false says not to repeat while Enter is held down, or holding it would open and close the field. The field fills the width of the window, and says press Enter to chat, in grey, while it's empty, which tells a new player how to start. It writes into our buffer, up to its size, so a line stops at two hundred bytes as we type. Enter returns true makes it return true when Enter is pressed, which also takes the focus away from it, and when there's something in it, the line is what we said, and the buffer goes back to zeroes. The client's library gets chat window dot c c, and in main, we include the header, and make a chat window next to the character screen. While we're connected, the window draws the connection's lines, and when it returns a line, the line goes to the server in an input. The input is a copy of the last one we sent, with say set, as the comment over it says: with what we sent last, so that we keep moving as we were. An input with nothing but say would tell the server that we let go of all the keys, and stop us, if we were walking while we chat.
11:14 Let's build it and run it with two players, and Ember's log along the bottom. Bob is in already, on the left, and alice logs in on the right, where she starts next to bob, in the middle of the island. She presses Enter, which puts her in the chat's field, and types a line for bob. As she types, her camera turns with each e, she turns and steps with each w, a, s and d, and she swings her sword at the spaces between the words. Enter sends the line, and it shows up in both chats, under alice's name, and in the server's log. So the chat works, while typing a line plays the game as well, as the game reads its keys from raylib whether or not the chat's field has them. The field is an ImGui widget, and it's easy to forget that the game reads the keyboard on its own, under it.
Typing, not walking
12:03 The game asks raylib which keys are down, in input dot c c, and ImGui gets its keys from raylib too, so a key that's down goes to both. ImGui's want text input tells the game when a text field takes the typing, and C has gone by it since episode seventeen. The game's other keys should keep working while a window like the settings has the focus, and stop while we type, so they go by that flag too. In input dot h, a new function, typing, returns a bool, next to movement and the other keys. Its comment says that it's whether the player is typing into a text field, such as the chat's, which takes the keys from the game. In input dot c c, typing returns ImGui's want text input, from its IO. Movement returns no direction while we type, before it looks at any key, running is false while we type, and so is attacking, so that Space goes into the line, and not into a swing. Q and E don't turn the camera while we type either, while the mouse keeps its own check, as dragging the camera doesn't get in the way of typing. In main, imgui dot h goes, as main uses ImGui nowhere else now. C opens the character screen unless we're typing, through the function the rest of the game's keys use now, and the comment over it goes, as the name typing says what it said.
13:26 Let's run it again, with the log along the bottom. I type a line with plenty of the game's keys in it, and this time I stand still while I type, the camera stays where it is, and the sword stays down. Enter sends the line to the chat and the log, and once it's sent, W walks me forward again.
Never trust the client
13:48 Our client stops the field at two hundred bytes, and a field of a single line can't take a new line. The server can't count on any of that, though, as the protocol is in our repo, and anyone can write a client that sends whatever it likes in say. That's the idea we've followed since the start of the series: the client sends what the player does, and the server decides what happens. A line with a new line in it would show up as two lines in each chat, and the second could start with bob and a colon, so that it looks like bob said it. A line can be as long as our framing allows, sixty-four kilobytes, and the server would send all of it to each player in the world. An empty line, or a line of spaces, shows up as a name with nothing after it. And bytes that aren't UTF-8 don't parse at all, as protobuf checks a string field when it parses it, so the server drops those, and we're going to pin that down in a test. So the server is going to clean each line before anyone sees it.
14:44 The rules for a line go next to its longest length, in chat dot h, in the protocol library, where the client could use them too. Optional, string and string view come in, for clean chat's types. Clean chat takes the text as a string view, so that it doesn't copy a line it's going to refuse, and returns the line as the other players get it, or nothing, as an optional. A line that cleans to nothing isn't an error, so an optional says it better than a status would. Its comment says what it does: control characters, such as a new line, become spaces, and the spaces at either end go. It returns nothing if that leaves nothing, or if the line is longer than k max chat length. In chat dot c c, which starts with its own header, and the standard headers it uses, there's Abseil's ascii header, for its checks on characters. Abseil's checks look at the char and nothing else, while the standard library's depend on the C locale. They're also undefined for a negative char, which is what a UTF-8 byte over a hundred and twenty-seven becomes wherever char is signed, as on x86. A line longer than two hundred bytes goes nowhere, before we copy anything. Otherwise, we copy the text into a string that we can change, and turn each control character into a space, which covers new lines, tabs and the rest of ASCII's control codes. The comment says why that's safe for UTF-8: UTF-8 writes the characters past ASCII in bytes over a hundred and twenty-seven, which this leaves alone, so we can't cut a character in half. Strip ASCII whitespace takes the spaces off both ends, and a line with nothing left goes nowhere, while any other line comes back clean. The protocol library gets chat dot c c, and its tests get chat test dot c c, which we write next. In server dot c c, we include optional, and chat dot h. Say cleans the text first, and returns when clean chat refuses it, so that the line that goes out is the clean one.
16:40 Our client can't send any of those lines, so a test is how we see the cleaning work, in chat test dot c c. It includes chat dot h, optional and string, GoogleTest, and our generated protocol header, for an input in the second test, and the tests go in an unnamed namespace, inside ours. Cleans what players say goes through the cases, a line each. The spaces at either end of hello go, which leaves the word on its own. A line with a new line in it, where the second half pretends to be bob giving alice his sword, comes out as a single line. The new line becomes a space, so that all of it reads as what alice said. French, with its accents, comes out as it went in, as those characters take two bytes each in UTF-8, both over a hundred and twenty-seven. Spaces and a tab come out as nothing, and a line of two hundred a's is the longest that goes through, while a single a more makes it too long. The second test pins down what we count on from protobuf, so that a test tells us if that changes, say with a new version of protobuf. An input says a single byte, hex f f, which can't appear anywhere in UTF-8, and parsing its bytes back fails, so the server drops that message, along with the rest of the input in it.
17:53 Compose builds the tests, and runs the protocol tests on their own, with a filter for the chat's two. Protobuf logs an error when the test serializes the byte, and another when it parses it, each saying to use bytes for raw bytes, and both of our tests pass.
Flooding the chat
18:10 Cleaning doesn't stop a player from saying a lot, and a client could send a line with each input, twenty times a second, or more. Each line goes to each player in the world, so with a thousand players, a single player who sends twenty lines a second makes the server send twenty thousand lines a second. And everyone's chat scrolls faster than anyone can read it. So the server is going to limit how fast each player can talk, and tell a player who goes over the limit, so that they know their line went nowhere.
18:40 The usual way to do that is a token bucket, where each player has a bucket that holds up to five tokens, and starts full. Tokens go back in over time, one each two seconds, so the time since we last looked, divided by two seconds, is how many come back, up to five, as the bucket can't hold more. Each line takes a token out, and when there's less than a whole token left, the line goes nowhere. So a player can say five lines at once, which is plenty for a quick back and forth, and then one each two seconds, for as long as they keep at it, while a pause fills the bucket up again. The bucket keeps two numbers, its tokens and when we last looked, and works the refill out when a line comes in, so there's no timer for each player.
19:25 The bucket gets a class of its own on the server, in token bucket dot h, with chrono for its times. There's nothing in it about chat, as a bucket can limit anything that comes in at a rate, such as logins. Its comment says what it does: it lets up to size through at once, and then one each refill, like a bucket of tokens that fills up again over time, where each one let through takes a token out. The constructor takes the size, and the refill as a duration of the steady clock, the clock that keeps going forward, unlike the time of day, which can jump. Take takes a token out, if the bucket has one at this time. It gets the time from its caller, instead of reading the clock, so that a test can hand it any time it likes, without waiting two seconds for real, like World's step takes the length of a tick. The bucket keeps its size and its refill, and its tokens as a double, as a refill puts back parts of a token. Last is the last time it looked, as a time point of the steady clock, which starts at the clock's epoch, long before any line. In token bucket dot c c, algorithm, for min, and chrono. The constructor starts the bucket full, with as many tokens as its size, as a player who just joined hasn't said anything yet. Take works out how many tokens came back since it last looked: the time since then, divided by the refill, as a duration of type double, which chrono turns into a plain number. On the first line, the time since the epoch is long, so the bucket is full. The tokens go up by that, up to the size, and now becomes the last time. With less than a token, the line doesn't get through, and otherwise, one token comes out, and it does. The server's library gets token bucket dot c c, and its tests get token bucket test dot c c, for later.
21:08 In server dot h, our header comes in, and chat limits keeps a bucket for each player, by id, in a map, like the other things we keep for each player. In server dot c c, two constants for the limit, k chat burst, five lines, and the comment over them says the limit in words: a player can say five lines at once, and then one each two seconds. K chat refill is two seconds, as chrono seconds, so that the unit goes with the number. When a player joins, they get a full bucket, and when they leave, it goes, along with the rest of what we keep about them. Say takes a token first, at the steady clock's now, before the cleaning, so that empty lines use up the bucket too. Without a token, the player gets a notice, a world with a single line of chat, with no name, that says you're talking too fast, so nobody heard that. It goes to their session alone, straight away, framed like the login server's replies, and say returns without adding the line to the tick's chat. In the chat window, k server is a warm yellow, for the lines from the server. A line with no name gets the yellow, pushed as the text's colour for a wrapped line, and popped after it, so that it doesn't read as something a player said, and continue goes on to the next line. The comment over it says that a line with no name is from the server. A player can't send a line without a name, as the server fills in the names.
22:29 Let's run it, with the log along the bottom, and say g g as fast as I can, a dozen times over. The first eight or so go straight through, from the full bucket and what comes back as I type. After that, the server's yellow line comes back for most of the rest, while the log has the lines that went out, and none of the others.
22:52 Then a test to pin it down, in a new file next to the bucket, token bucket test dot c c, which includes our header, chrono and GoogleTest. A using declaration for seconds saves us its namespaces, as we're going to write it a lot. Lets a burst through, then one each refill, makes a bucket of five, with a refill of two seconds, and starts at the clock's epoch, which is what a time point is by default. Five lines at the start all get through, and a sixth at that time doesn't. A second later, half a token has come back, which isn't enough, while at two seconds, it adds up to one, and the line goes through. A minute later, five lines get through, and the comment over them says why that's all: a long wait fills the bucket, and no more. So a sixth line at that time doesn't get through.
23:40 We run the server's tests with a filter for the bucket's test, and without the databases, as the bucket needs none, and the test passes.
Chat under load
23:49 With bots that talk, we'd like to see the chat on our dashboard, and the refused lines too, which tell us when someone floods it. In metrics dot h, add chat line counts a line by what became of it, said or refused, as its comment says. Chat lines is a family of counters, with a label for the result, like the players by land from last time. In metrics dot c c, the constructor registers the family, with nanommo chat lines total as its name, and its help text, lines of chat, said or refused, which Prometheus shows with it. It adds no counter yet, as each result gets its own when it first happens. Add chat line adds one to the counter for that result, which add makes the first time it sees the result, and finds after that. In server dot c c, a line without a token counts as refused, and so does a line that clean chat refuses, while a line that goes out counts as said. The dashboard gets a panel under the others, chat lines a minute, with the counter's rate over a minute, times sixty, for each world and result.
24:52 And our bots are going to talk, so that the load has chat in it, and a player among the bots sees the chat move. In bot dot c c, array and cstddef come in, for a few lines that a bot can say, as constants, and the comment says when it says them: now and then, when it gets where it was going. Hi all, anyone up for the Ashlands, careful, skeletons over here, nice view from up here, where is everyone going, and b r b, for be right back, the kind of lines that fill an MMO's chat. In wander, a bot has nothing to say, until its trip ends. There, a Bernoulli distribution, like the one that has a bot run a third of the time, says something one time in four, and the line is one of ours, picked at random. The input it sends next carries the line, and as an empty string isn't sent at all, the other inputs stay as they were.
25:44 Let's start the world with Prometheus, Grafana and a hundred bots, which say something on one trip in four. After a couple of minutes, the new panel at the bottom counts the lines that Ember's bots say each minute, which climbs as the bots finish their first trips. All of them are said, and none refused, as a bot stays well under the limit.
Docs
26:04 Last, the README, where the controls mention the chat, the list of metrics has the lines of chat, and the bots' section says that they talk. A new section on chat says how to use it, that the game's keys wait while you type, and how a line gets to everyone, with the input, and over TCP in a message at the end of the tick. It says what the server cleans out of a line, and how fast a player can talk. It ends on what world chat costs, as each line goes to each player in the world, while chat for the players nearby, or for a party or a guild, would send each line to far fewer.
Wrap-up
26:38 So, today players can talk to everyone in their world, with a field in the input and the lines in the world, and the server sends each tick's lines to each player over TCP, as each line has to arrive. The client has a chat window, where Enter starts and sends a line, and the game's keys wait while you type, after a first try where typing a line walked us around and swung the sword. The server cleans each line before anyone sees it, and a token bucket for each player keeps anyone from flooding the chat, while our bots talk now and then, and the dashboard counts the lines.
Playing it
28:00 Let me know in the comments what you'd like to see next. And if you want to follow along with the series, subscribe to the channel. I hope you find this useful, and thanks for watching.