Episode 6 of a series about building an MMO from scratch, with the focus on the engineering around the game. Our players log in, with the accounts from episode five. A new login server checks passwords away from the game server's tick, and hands each login over through Redis as a one-time token that expires after thirty seconds. The game server takes the token, loads the player and saves where they are, so you come back where you left off. The client gets a login screen and names over the players, and the bots log in too. Along the way, we go over why logins get a server of their own, tokens in Redis or signed ones, and integration tests against MySQL and Redis, in CI too.
The code
Each step on screen is a tag in the game's repository. The code at the end of the episode is ep006/end.
Transcript
Intro
0:00 Hello and welcome back to episode six, where our players log in, with the accounts from last time. There's going to be a login screen in the client, a login server, and Redis to hand each login over to the game server. Let's begin with a quick look at where we're going to be by the end of the episode.
0:17 Two players create their accounts, on the new login screen. The login server keeps the accounts in MySQL, and gets each client into the game server with a token, through Redis. So each cube has a name over it now, and the server keeps where each player is, for the next time they log in. Later on, we will also build a small website, where you can register and log in with your game account.
0:42 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:54 Now, before we get to today's work, let's quickly go over what we did last time. We started MySQL with Docker Compose, with its tables in a file in the repo, and went with SQL, as our data is full of relations. Passwords are hashed with Argon2id, slow on purpose, through libsodium, and our database class talks to MySQL through its C API, with prepared statements. On top of it, the accounts class creates and finds accounts, and tests run all of it against MySQL in a container. Nothing logged in with those accounts yet, so all right, let's get started on the logins.
Today
1:29 Today, we're going to let each player log in, so that the server can tell who's who. The client opens on a login screen, with a name and a password. It sends them to a new server, the login server, which checks them against the accounts in MySQL, from last time. Once you've logged in, the login server puts a token in Redis. The token is just a long random string that works a single time, and for half a minute. The client sends the token to the game server, which then looks it up in Redis to link to your account. And the game server saves where each player is in MySQL too, so you come back where you left off.
A login server
2:05 Logins get a server of their own, as checking a password the right way is slow on purpose, tens of milliseconds on my machine, as we measured last time. Our game server does everything on one thread, and a tick has fifty milliseconds for the whole world. So a single login would hold up the world for everyone, for longer than a tick. A login server takes that work, and the game server keeps its thread for the game. The split is separation of concerns too, as the game server doesn't see a password at all. And later on, one login server can let players into several game servers. If you've played Cabal or AION, that's the step where you log in first, and then pick a server or a channel. The two servers need a place where both can see who has logged in, and we're going to use Redis for that.
Redis
2:52 Now, Redis, which is a database that keeps its data in memory. Each value has a key, which is a name we pick. And besides plain strings, a value can be a list, a hash, a set, or a sorted set, which comes in handy for things such as leaderboards. Since it's all in memory, reads and writes take well under a millisecond. It can also save its data to disk, so that it survives a restart. Any key can have a time to live, after which Redis deletes it for us. And any of our servers can reach it over the network. Redis is a good fit for our tokens, as they're small, they're looked up by key, they're shared between the two servers, and they should disappear after half a minute. In MySQL, we'd need a tokens table, and a job that deletes the old ones, for data we don't want to keep. There's another way, which is a signed token, say a JSON web token, that the game server can check on its own, without asking anyone. That works too, and it saves the game server a trip to Redis. However, a token in Redis works once, and we can take it back at any time, by deleting it.
3:59 Redis's documentation has a page for each command, with its options and what it replies. SET sets a key to a value, and EX gives it a time to live, in seconds, in that one command. And GETDEL gets a key's value, and deletes the key, in one step, so that no other client can get it in between. Those two are all our tokens need, one to issue a token and one to redeem it.
4:24 To talk to Redis from C++, we're going to use hiredis. It's Redis's own minimalistic C client library, and it builds with CMake, so FetchContent can fetch it like our other libraries. We're going to use its synchronous API, which comes down to three functions. Redis connect connects to a server, redis command sends a command and waits for the reply, and free reply object frees the reply once we've read it.
4:51 First, Redis joins compose dot yaml, as a service from the official redis image, pinned to eight point eight point three. Its port, six three seven nine, is published on my machine, for the servers and the tests we run outside Docker. Its healthcheck runs redis-cli ping, which answers PONG once Redis is up, two seconds apart, like MySQL's. We don't give Redis a volume, as the tokens last thirty seconds, so there's nothing in there worth keeping across a restart. Then the tests service gets nanommo Redis host, pointing at the redis service, and waits for it to be healthy too. So in the CI workflow, the step that runs those tests says that it runs them against MySQL and Redis now. In the top-level CMakeLists, we fetch hiredis, like most of our libraries. Before that, we set build shared libs to off, as by default, hiredis builds a shared library. We want a static one, linked into our servers, so that their images don't need another file next to them. Disable tests skips building hiredis's own tests, which we'd compile and not run. Then FetchContent declare points at the release archive of version one point four point one. SYSTEM marks its headers as a system library's, so that our strict warnings don't fire on its code. Make available downloads it, and adds it to our build. In the server's CMakeLists, the core library gets redis dot c c and tokens dot c c, and links with hiredis, while the tests get tokens test. Now a small class of our own, so that the rest of the server doesn't have to handle C pointers and replies. It goes in redis dot h, and after the header guard, it includes hiredis dot h, then memory, string and vector, and Abseil's status or. The Redis class is a connection to Redis, and connecting can fail, like with MySQL, so Redis gets a static create too. It takes the host, and the port, which defaults to six three seven nine, Redis's own port, so most callers give the host and nothing else. It returns a status or of a unique pointer to a connected Redis. Its comment is like the database's: connects to Redis, or returns why it couldn't. The destructor frees the connection, and copying is deleted, like the database, so two objects can't free one connection. Command runs a command, given as its words, such as GET and a key. Over it, we add that it returns the reply as text, or a not found error when Redis says nil. Text covers all the replies we read, as a number such as a time to live can be parsed back out of it. Nil is how Redis says that a key doesn't exist, and it's the answer we're going to get for an unknown token. The comment goes on: a command that loses the connection fails, and the next one reconnects. Privately, the constructor takes hiredis's connection, a pointer to a redis context, which holds the socket, the host and port to re-connect to, and the last error. It's explicit, like the constructor of the accounts, as it takes a single argument. The member keeps that pointer, so that each command can send through it. Then the source file, redis dot c c, where after our header, it includes cstddef, for size t, then memory, string, string view and vector. From Abseil, memory, for wrap unique, then status and str cat, which joins text and numbers into one string, for our messages. In our namespace, an unnamed namespace comes first, like in database dot c c, for a helper that stays in this file. Reconnect if lost is a helper that runs at the start of each command. We go back up and comment it: a lost connection, say from Redis restarting, leaves its error on the context, and hiredis can connect again with the settings it kept. The context's err field is that error, and redis reconnect connects again, to the host and port the context kept. If that fails too, we return an unavailable error, with hiredis's error string, and otherwise OK, which is also what we return when nothing was lost. Create comes next, with the host and the port, and the status or it hands back. It calls redis connect, which connects right away, and waits until it's done. A null pointer means it couldn't even allocate the context, which we report as resource exhausted, out of memory. Otherwise, the context goes into a Redis object right away, with wrap unique, like the database. Over that line goes a comment: the object owns the context from here, so an error below frees it. The context's err is set when connecting failed, and then we return an unavailable error, with hiredis's error string. Hiredis hands back a context even when it couldn't connect, so that we can read why from it. Otherwise, we return the connected Redis, and its destructor frees the context, with redis free. A context that reconnected is still the one we got from redis connect, so it's the one we free. Command starts with reconnect if lost, and returns its error if Redis is still away. It doesn't try a command again after losing the connection, as Redis might have run it before the connection went. Say, a GETDEL that ran would have deleted the token, and a second try would say there's no such token. The database could retry, as preparing doesn't run the statement, while a Redis command runs as soon as it arrives. For the rest of command, we're going to use redis command argv, instead of the README's redis command. That one takes a format string, a bit like printf, which is easy to get wrong with values that come from users. Say, if a name with a percent sign in it ended up in the format string, it would be read as part of the format. The argv version takes the words as an array, each with its length. First, argv collects a pointer to each word, without copying the words. Its comment says why: each word goes with its length, so it can hold any bytes, spaces and all. So hiredis doesn't look for a zero at the end of each word, as it's told how long each one is. That's what lengths is for, with each word's length, and a loop over the words fills both. Then the call, whose reply is going to be held by a unique pointer to a redis reply, with free reply object as its deleter. Decltype gives us that function's pointer type, to name in the unique pointer's type, like with the database's lengths. So the reply is freed on any of the returns below, like the database's statements. Inside it, redis command argv sends the words to Redis, with how many there are, the pointers and the lengths, and waits for the reply. It hands back a void pointer, which we cast to a redis reply. And the last argument gives the unique pointer free reply object, to call when it's done with the reply. A null reply means a closed connection, and we return unavailable, with hiredis's error. Then a switch on the reply's type, where nil is a not found error, as Redis has nothing there. An integer comes back as text, with str cat, and that's how commands such as TTL reply. A string or a status, such as OK, comes back as a string, made from the bytes and their length. So a zero byte in the middle doesn't cut it short. An error comes back as an internal error, with Redis's message. And the default is an unimplemented error, which is Abseil's code for something we haven't written. That's for the types we don't read, such as arrays. We don't need them yet, and the error tells us right away if we start using one by accident.
12:25 With Redis in reach, the tokens get a class of their own, in tokens dot h. It includes chrono, which we saw in episode one, for the token's lifetime. Then cstdint and string, Abseil's status or, and our Redis header. The Tokens class keeps how a token is stored in Redis in one place, like Accounts does for its table. K lifetime is how long a token works after it's issued, thirty seconds, as a chrono duration, so its unit is part of its type. It's static constexpr, like the world's speed in episode three, so the tests can read it too. Thirty seconds is plenty for a client to go from one server to the other, and short enough that a token that leaks, say into a log, is soon worthless. The constructor takes the Redis connection, and it's explicit, like the others. Issue makes one for an account, and returns it, or the error that stopped it. And redeem gives back the account a token was issued for. Redeeming deletes the token as well, so each token works once. Above it, we write down what it returns: a not found error for a token that's unknown, used or expired. From the outside, those three look alike, and the game server has no need to tell them apart. The member is the Redis reference, which main owns and the tokens borrow, like the accounts borrow the database. In the source file, we include sodium dot h again, this time for its random numbers, then array and string. From Abseil, its check, numbers, for simple a to i, and str cat. Then an unnamed namespace, for what stays in this file. K prefix starts each token's key, token colon, so that tokens are easy to tell apart from other keys we put in Redis later on. It's a constexpr array of chars, and not a std string. The style guide keeps constants that live as long as the program to types that need no code to build them or tear them down. A std string needs code for both, so it can't be one. The constructor sets up libsodium, as issue uses its random numbers. It checks the result with Abseil's check, like the password code. In issue, bytes is thirty-two bytes, and randombytes buf fills them from the operating system's secure random numbers. A token has to be hard to guess, as a predictable one would let somebody into another player's account. A generator such as rand, from the standard library, can be predicted from the numbers it gave before. Then hex is an array of chars, two for each byte, and one more for the zero at the end. Sodium bin2hex writes the bytes out as sixty-four hex digits, which are safe to send, and to use in a Redis key. Two to the power of two hundred and fifty-six possible tokens is far too many to guess. Those hex digits become a string, which is the token. Then SET, with the key, the account's id as the value, and EX, with the lifetime in seconds, from count. Str cat puts the prefix and the token together, and turns the numbers into text. Redis deletes the key for us after that, so no job of ours has to clean up old tokens. If Redis couldn't store it, we return its error, as a token that isn't in Redis would let nobody in. Otherwise, we return the token, for the login server to send to the client. Redeem takes the token that a client hands to the game server, and runs GETDEL on the token's key. Above the call, we write why: GETDEL reads and deletes at once, so two clients can't use one token. With a GET and then a DEL, two clients could both read the key before either of them deletes it. If there was no such key, command has returned a not found error, and redeem hands it on as it is. Otherwise, the value is the account's id, as text, and simple a to i turns it back into a number. A value that isn't a number would be a bug of ours, so that's an internal error. The test helpers include our Redis header, and get test redis. Its comment reads: Redis at nanommo Redis host, like test database. It returns a null pointer when that variable isn't set. Otherwise, it connects with Redis create, and if that fails, add failure records it, like for MySQL. The tests service in compose dot yaml already sets that variable, so in Docker, these tests run against the redis service. Tokens test includes tokens dot h, memory and string, then Abseil's status, status or and numbers, gtest and the helpers. Numbers is for simple a to i again, as Redis replies with text. Its fixture, TokensTest, connects in set up, with test redis. If there's no Redis, it skips, and says which variable to set. Its member is the connection, which the tests use for the tokens and for asking Redis directly. There are three tests, two for the things a token has to do, and one for losing Redis. The first, redeems a token once, makes the tokens on top of that connection, issues a token for account seven, and asserts that it worked. Then it redeems it, and value or gives the value inside a status or, or the fallback we pass it if there's an error, here zero. So a failed redeem shows up as a wrong number in the test's output, and we expect seven. Then it redeems the token again, and expects a not found error, with is not found. A token that worked twice could let somebody in on someone else's login. Tokens expire issues a token, asserts that it worked, and asks Redis directly for the key's TTL, which is its time to live, in seconds. The reply is text, so simple a to i turns it into a number. One assert checks both that the command worked and that its reply is a number. It expects the TTL to be above zero, so the key does expire, and no more than k lifetime, so it doesn't live longer than we said. We don't wait thirty seconds in a test, as asking Redis is quick, and tells us the key is going to go. Reconnects after losing Redis asks Redis for our connection's id, with CLIENT ID, and then has Redis close it, with CLIENT KILL. Skip me no is there because by default, Redis doesn't kill the connection that asks. Then issuing a token fails with unavailable, as that's the command that finds the connection closed. And the next one works, as command connected again first. Back in redeem, GETDEL is the line that makes a token work once. Redis runs one command at a time, so a read and a delete in one command can't be split by another client.
19:01 Docker compose up starts Redis, in a second or so. All three token tests pass against it, with nanommo Redis host pointing at our machine. Now let's do by hand what the tokens do, with redis-cli, Redis's command-line client, inside its container. SET token colon abc to seven, with EX thirty, and Redis answers OK. TTL says how many seconds the key has left before Redis deletes it. GETDEL gives us seven, and deletes the key, so the second GETDEL gets nil.
The login server
19:40 Now the login server, which is going to talk to clients over TCP with framed protobuf messages, like the game server. So it starts in our contract, nanommo dot proto, where a client sends the login server a LoginRequest. It has the username, the password, and new account, a bool that asks to create the account first. One message for both keeps the login screen and the login server simple, as there's a single request to send and to read. The login server answers with a LoginReply, where ok says whether it worked. With ok, the token is what the client sends to the game server. Otherwise, error says why not, as a sentence the client can show the player as it is. For its connections, the login server is going to use our Session class from episode three, which reads framed messages and writes them in order. However, it has to reply on the session a message came from, and so far, all the handler gets is the message. So in session dot h, the message handler now gets the session too, by reference, along with the message. And close moves from private to public, so that a handler can close the connection, say over a message that makes no sense. In session dot c c, the handler is called with the session. Then, if the handler has closed the session, we stop reading right away. The reader may still hold more messages from that client, and we don't want to handle them. And in the game server, the message handler takes the session as well, to fit the new type. It doesn't need it yet, so the parameter has no name, which tells the compiler that we're not using it on purpose.
21:16 The login server gets a header and a source file of its own, like the game server has. The header comes first, and after its guard, we include cstdint, for the port, and memory, for the unique pointer that create is going to hand back. Then Abseil's status and status or, for the results that can fail. From Asio, the I O context and TCP, for the networking, one header for each part we use. Then nanommo dot pb dot h, which is the code protoc generates from our contract, and our accounts and tokens headers. Handle login is where the decisions are, and it's a free function, apart from the networking. It answers a login request, so it creates the account first if it's a new one, checks the password, and issues a token for the game server. Besides the request, it takes the accounts and the tokens as arguments. That way, the tests can call it directly, without a socket. It returns a status or of a LoginReply, as there are two kinds of failure here. Its doc comment says that it answers a request with a token, or with an error the player can read, such as a wrong password. Returns an error status when the servers it uses can't answer, such as MySQL or Redis being down. A wrong password is an answer for the player, so it goes in the reply. MySQL being down isn't the player's doing, so it's an error status, for us to log. The LoginServer class is the networking around it, which accepts clients over TCP, and answers each LoginRequest with a LoginReply. Listening on a port can fail, say when another program has it, so it gets a static create, like our game server in episode three. Create takes the I O context, the port, the accounts and the tokens. Its comment says it starts listening on the port, or returns why it couldn't. Privately, the constructor takes the I O context and the two references. Start listens on the port, and returns a status, for create to hand back. Accept waits for the next client, and hands it a session of its own. The members are the acceptor, which takes in the connections, and the two references, to the accounts and the tokens. The login server uses them, and main owns them, so main has to keep them for as long as the login server runs. Then the source file, login server dot c c, where after its own header, we include iostream, for logging, memory, for the sessions' shared pointers, then string and utility. From Abseil, memory, for wrap unique, while status, status or and Asio's parts come with our header. Then our framing, listen from episode three, our password header, and session. Inside our namespace, handle login comes first, taking the request, the accounts and the tokens, and building the reply as it goes. It starts with an empty reply, and id, the account's id, which stays zero until we know which account it is. Account ids start at one, so zero means none yet. For a new account, problem is what check username says about the name. If the name is fine, problem becomes what check password says about the password. We check those here, on the server, as anyone can send us a request without going through our client. If there's a problem, its message goes into the reply, as the error for the player, and we return the reply. Otherwise, we hash the password, and if hashing fails, that's on our side, so we return its status. Then accounts create makes the account, with the hash, so the plain password doesn't go anywhere near the database. Is already exists looks at the code of create's status. If the name is taken, the reply says so, while any other error goes up as a status. Otherwise, id is the new account's, which create handed back. For a login, accounts find looks up the name, and gets back its id and hash. An error that isn't not found means we couldn't ask MySQL, so it goes up as a status. Then, if there's no such account, or the password doesn't verify, the error is wrong name or password. We give one answer for both, as either way, the player tries again. And as names in our game are over each player's head, and signing up says when one is taken, a name isn't a secret to keep. Otherwise, the id is the account's, from what find gave back. At the end, tokens issue makes a token for that id, and an error there goes up too. Then ok is true, and the reply carries the token. The rest of the file is the networking around it, for the clients that connect. Create has the header's signature again, without static, like the database's. It makes the login server with wrap unique, as its constructor is private. Then it calls start, with the port, and if start fails, we return its status. The half-made server is deleted on the way out, and otherwise, we return it. The constructor makes the acceptor on the I O context, without opening it yet, and keeps the two references. Start calls listen, our function from episode three, which opens the acceptor on the port, on all of the machine's IPv4 addresses, or returns an unavailable error with the reason. Then start calls accept, to start taking clients, and accept works like the game server's. It waits for a socket, and once it has one, it calls accept again, for the next client. For each new socket, on message is a lambda that captures this, so it can reach the accounts and the tokens. It parses a LoginRequest from the bytes, and closes the session if it can't, as a client that sends us something else isn't one of ours. Then it calls handle login, and keeps what it hands back as handled. If that's ok, the reply is moved out of it. If there's a problem, we log it, and the reply says try again later. So the client still gets an answer, and the login server keeps running for everyone else. Then a log line for each request, so that we can follow in the terminal what the login server is doing. If it worked, we log who logged in, or who created an account. If it didn't, we log who was refused, and why, as a run of refusals for one name would show someone guessing its password. The password stays out of the log, as logs get copied around and kept for a long time. And session send sends the reply back to the client, framed like the request. Each connection gets a session, made with make shared, with that handler, and a close handler. It does nothing, as the login server keeps no state for a client after its reply. The tests include our login server header, memory and string, Abseil's status or, gtest and the helpers. They run handle login against MySQL and Redis, so the fixture, LoginTest, connects to both in set up. It asks the helpers for a database first, and then for Redis. If either one is missing, it skips, with a message that names the two variables to set. With both there, set up makes the accounts and the tokens on top of the two connections, like the login server's main is going to. Tear down deletes the test's account afterwards, and expects that to work, like in the accounts tests. Log in is a helper for the tests, and it takes a password and whether it's a new account, which is false unless we say otherwise. It fills in a LoginRequest with the test's name, the password and the new account flag. Reply is a status or, from handing the request to handle login. These tests are about the answers a player gets, so the helper expects reply to be ok. If it isn't, the stream after it prints the status. Then value or returns the reply, or an empty one after an error. So the test carries on, and fails on what it checks next. Each test is down to the passwords it tries, and what it expects back. The members hold the two connections, and then the accounts and the tokens. Each one is a unique pointer, as they're made in set up, once we know the connections are there. And last comes the test's name, from test name, like in the accounts tests. There are three tests, one for each answer a player meets most. A new account gets a token creates an account, with correct horse as its password. It checks that the reply is ok, and that its token redeems, so the game server would let the player in. The second, logging in with the right password, creates the account, and logs in with correct horse. Then it tries battery staple, and that gets the error, wrong name or password, and no token. Refuses a name that's taken creates the account first, and asserts that it worked, as the rest of the test would mean nothing otherwise. Then it tries to create it again, with another password, as somebody else might, and gets told that the name is taken. Each test starts from a fresh name, so they don't depend on each other, or on the order they run in. Let's go back to the login path for a moment, where a wrong name and a wrong password get one answer. So the password is what keeps an account safe, and Argon2id makes each guess slow and expensive.
30:21 The servers need the addresses of MySQL and Redis, and those are different on my machine and in Docker Compose. Environment variables are the usual way to tell a program that, as Docker and Compose can set them without a config file in the image. So settings dot h includes cstdlib, for get env, and string, and has setting. It returns an environment variable's value, or the fallback when it isn't set. It's inline, like the test helpers, so each source file that includes it can have a copy. Get env returns a null pointer for a variable that isn't set, and that's what we check for. Otherwise, the value comes back as a std string, a copy, so the caller doesn't hold on to memory that belongs to the environment. The login server gets a main of its own, login main dot c c. It includes csignal, for the signals that stop it, then cstdint, iostream, memory and utility. Then Abseil's status or, Asio's I O context and signal set, and our accounts, database, login server, Redis, settings and tokens headers. Its port is seven seven seven eight, next to the game server's seven seven seven seven. Then comes the database, and database create connects with its host from nanommo MySQL host, and one two seven dot oh dot oh dot one as the default. The user is nanommo, the password comes from nanommo MySQL password, with nanommo as the default for our own machine, and the database is nanommo. Those defaults match compose dot yaml, so on my machine, there's nothing to set. Above it goes the reason: to MySQL's client, localhost means a socket file, so we go over TCP. Our MySQL is in a container, with its port published, and there's no socket file for it on my machine. Database or is the status or, with Google's or at the end of its name, like in episode three. If it isn't ok, say MySQL isn't running, we print the status, and return one. That way, Docker, or a script, can tell that the login server failed. Otherwise, star std move takes the database out of the status or, like the window in the client. Then Redis, from nanommo Redis host, with one two seven dot oh dot oh dot one as the default too. It's checked like the database, and moved out of its status or. On top of the two connections go the accounts and the tokens. Both take their connection by reference, so the star gives them the object the unique pointer holds. Then the I O context, which runs Asio's work, like in the game server. Server or holds what login server create gives back, for our port, with the accounts and the tokens. If the port is taken, that's an error status with the reason, which we print, and return one too. Otherwise, the server comes out of its status or, and a log line says it's listening. The signal set and its async wait stop the I O context on Ctrl C or docker stop, like in the game server. Io run handles the clients until then, on this one thread, and after it returns, we log that the login server stopped. So stopping it is clean, and Docker doesn't have to kill it after its timeout. In the server's CMakeLists, the core library gets login server dot c c. A new executable, nanommo login, is built from login main, and links with the core, which has everything else. And the tests get login server test, so they run with the rest. In the Dockerfile, a new stage, login, starts from runtime, so it has libsodium and MySQL's client library. It copies nanommo login from the test stage, so that, like the game server, it's built after the tests pass. It runs as the ubuntu user, exposes port seven seven seven eight, and its entrypoint is the login server. In compose dot yaml, a login service is built from that stage. Its environment points it at mysql and redis, by their names on Compose's network, and its port is published. Depends on waits until both are healthy, so that it doesn't start before it has somewhere to keep the accounts. And in CI, the step that builds the server's image now builds the login server's image too.
The game server
34:41 Next, the game server has to let players in with a token, and keep where they were. In nanommo dot proto, each Player gets a name, as field four, so that the clients can show it over the cube. Adding a field with a new number keeps the message compatible, as an older client skips a field it doesn't know. And a new message, Hello, is the first message a client sends the game server, with the token from the login server in it. In world dot h, we include string, as a Player has a name now. Join used to make up the next id, and line the players up across the middle of the board. Now the id comes from the player's account, so that it stays yours from one login to the next. So join takes the id, and the player, which comes from the database with its name and where it was. The world doesn't need to count anymore, so the next id member goes. In world dot c c, we include utility, for moving the player in. Join drops the code that made up an id and a position. Now it puts the player into the map, under its id, and std move moves it in, so that its name moves along instead of being copied. In the world's tests, a new test, joins players where they are, joins player seven, alice, at two and minus one. Then it checks that the world has that player, with that name and that position. That's what the game server is going to count on, when it loads a player from MySQL. The other four tests used to get an id from join. Now each one picks id one, and joins it with an empty player, which starts in the middle of the board, so what they test doesn't change.
36:20 Players dot h is the game server's table, like accounts is the login server's. It includes cstdint, Abseil's status and status or, our database header, and the world, for the Player struct. Keeping the SQL in a small class like this, out of the server, makes it easier to move to another database later on. The constructor takes the database, and it's explicit, like the others. Load returns an account's player, in a status or: its name, and where it was when it last left, or the middle of the board the first time. The game server is going to call it when a player joins. And save keeps its position, for the next time it joins. That's for when a player leaves, and when the server stops. It returns a status, which is OK once the row is written. The member is the database reference, like in accounts, as main owns the connection, and the players borrow it. Then the source file, players dot c c, where after its header, we include string, for to string, and vector. Then Abseil's numbers, for turning text into numbers, and str cat. In our namespace, load comes first, and it takes the account's id, and keeps what run gives back, as result. The statement is a SELECT of the account's username, and the player's x and z. The name lives in accounts, and the position in players, so the statement joins the two tables, on the account's id. It's a LEFT JOIN, which keeps the account even when it has no player yet, and gives NULLs for x and z. That's the case for a new account, as we don't write a player until it leaves the game for the first time. COALESCE turns those NULLs into zero, which is the middle of the board. So a new player and a returning one take one query, with no extra step for the first login. The account's id goes in as the statement's value, as text, and an error from run goes up to the caller. If there's no row, the account doesn't exist, and that's a not found error, with the id in its message. That shouldn't happen, as tokens are issued for accounts that exist, so if it does, we want to hear about it. Otherwise, the player gets the name, with a designated initialiser. Simple a to f turns the text back into floats, for x and z, and returns false for text that isn't a number. That would mean a broken row, so it's an internal error, and otherwise, load returns the player. Save takes the account's id and the player, and keeps its position. It runs an INSERT into players, with the account id, x and z. If the account has a player already, the primary key would be duplicated, and the insert would fail. So ON DUPLICATE KEY UPDATE updates x and z instead. AS saved gives the row we tried to insert a name, so that the update can take its values from saved dot x and saved dot z. That's called an upsert, one statement that inserts or updates. MySQL does it in one step, so there's no gap between checking and writing, like with the accounts. Save returns the status from run, which is OK, or the error that stopped it. So the caller decides what a failed save means, and for the game server, that's a line in its log. Then the tests, in players test dot c c, which include the players header, memory and string. Then Abseil's status or, gtest, then our accounts header and the helpers. The fixture, PlayersTest, connects in set up, through test database, and without MySQL, it skips, like the accounts tests. Otherwise, id is a status or, from accounts create, which makes an account for the test. A player needs an account to belong to, and the foreign key would refuse a player without one. The hash is a made-up one, as nobody logs in here. Set up asserts that the account was created, and keeps its id. Tear down deletes the account afterwards, and expects that to work. Deleting the account deletes its player too, through the cascade, so the test leaves no rows behind. The members are the database, the test's name, and the account's id, which starts at zero until set up fills it in. Starts a new player in the middle loads the player of a fresh account, which has no row in players yet. It loads it through a Players made on the spot, as that's all this test needs, and asserts that the load worked. It expects the account's name, as load takes it from the accounts table. And it expects zero for x and for z, the middle of the board, which is the left join and COALESCE at work. Loads where the player was saved makes a Players object for the test, on the test's database. Then it saves the player twice, at two and a half and minus four, then at three and a half and minus one, and asserts that each save is OK. Then it loads the player again, into a fresh status or. It asserts that worked too, and expects the second position. Saving twice is what proves the upsert, as the second save has to update the row that the first one inserted. In the server's CMakeLists, players dot c c joins the core library, and players test joins the tests.
41:32 In server dot h, we include string, our players header and our tokens header. Create takes the tokens and the players too, by reference, as main owns them. Save all saves where each player is, for when the server stops. Privately, the constructor takes the two as well, from create. Handle hello lets a client in if its Hello has a good token. It returns its player's id, or zero if it doesn't get in. Account ids start at one, so zero is free to mean nobody. Leave handles a player who leaves, and the members get the two references. In the source file, create takes the tokens and the players, and passes them on to the constructor. The constructor takes them too, and its initialiser list keeps the two references, after the members it had. They follow the metrics, like in the class, as members are set up in the order they're declared. Save all goes through the players in the world, and saves each one, keeping the status as saved. If a save isn't ok, we log the player's name and the status, and carry on with the others, so one bad row doesn't cost everyone their position. In accept, a new connection doesn't join the world right away anymore. Until its Hello has been checked, we don't know who it is, so its id starts at zero. The id is a shared pointer, as both handlers need it, and the message handler is going to change it. A shared pointer gives them one id between them, which lives as long as they do. On message now uses the session it's given, and it counts the message, as before. Then, while the id is zero, the message has to be the Hello, and handle hello decides the id. After that, messages are input, as before, for that id. On close calls leave, if the player got in, as a client that didn't get in has nothing to save. The session doesn't go into the sessions map here anymore. That happens once it's in the world, so a client without a token doesn't get sent the world each tick. Handle hello takes the session and the bytes of the first message, and returns the id it decides on. It makes a Hello message, and then id, which is a status or. If the bytes parse as a Hello, id is what redeeming its token in Redis gives back. If they don't, id is an invalid argument error, not a hello, so both ways end up in one status or. If id is an error, or that player is in the world already, we log that we refused a client, for an unknown token, or a player who's already playing. Then we close the session, and return zero, so the client stays out. Two clients playing one account would fight over one cube, so the second one is refused. Otherwise, players load reads the player from the players table, with its name and where it was. We ask MySQL after the token check, so a client without a good token doesn't cost us a query. If that fails, we close the session, and return zero, as there's no player to put in the world. Otherwise, the player joins the world under its account's id. The session is kept under that id, for the broadcast, and shared from this gives us the shared pointer the session lives in. The players online go up, and we log the player's name, and its id. Then handle hello returns the id, which the message handler keeps. Leave takes the id of a player who's in the world. It looks the player up, logs who left, and saves the player, so its position is in MySQL before it leaves the world. A failed save is logged, with its status, and doesn't take the server down, as the other players are still playing. Then the player leaves the world, its session goes, and the players online go down. And in broadcast, each player's name goes into the world message, so that the clients can draw it. In main, we include the database, players, Redis, settings and tokens headers. Before the server, database or holds what database create gives back. It connects to MySQL the way the login server does, with the settings and the defaults we gave it there. If it isn't ok, we print the status, and return one, as a game server without its database can't let anyone in. Otherwise, the database comes out of its status or, like in the login server. Then redis or, from Redis create, with its host from nanommo Redis host. If that isn't ok, the status goes to the log, and main returns one again. Otherwise, the Redis connection comes out of its status or too. After that, we make the tokens, on top of Redis, and the players, on top of MySQL. Each takes its connection by reference, through the star, like in the login server's main. Server create gets both, after the metrics, as two more references. After the loop, save all saves everyone, so that stopping the server, with Ctrl C or docker stop, loses nobody's position, and the log line says so. These calls to MySQL and Redis block the tick's thread, for a millisecond or so, when a player joins or leaves. That happens a lot less often than ticks, and our tick histogram from episode four is going to tell us if it starts to matter. In compose dot yaml, the game server gets the environment to find MySQL and Redis by their service names, mysql and redis. Then depends on waits until both of them are healthy, like it does for the login server. Back in handle hello, this check decides whether a client gets into the world. Without a good token from Redis, a client gets its connection closed, and doesn't see the world.
47:00 Before we change the client, let's run what we have, with MySQL and Redis in Docker. I start the game server in the background, in this terminal. Then three of the bots from episode four, which connect to the game server directly, without logging in. The server reads each bot's first message as its hello, and as it carries no token, the server refuses the bot, and closes its connection. So nobody gets into the world without the login server, which is what we want, and next, the client gets a way to log in.
The login screen
47:30 On to the client, and the bots after it, which are both going to log in. So the code that does it goes into the protocol library, which they both link with already. It gets a header and a source file there, next to the framing. The header is login client dot h, and after its guard, it includes cstdint, string, Abseil's status or, and the generated messages. It declares log in, which takes the login server's host and port, and a request, and returns a status or of the reply. Like the others, it gets a comment: sends the request to the login server and waits for its reply, or returns why it couldn't. So a login server that's down comes back as an error, which the caller can show to the player. In the source file, after its header, we include array, for the read buffer, then optional and string. Then Abseil's status and str cat, for the error messages. From Asio, buffer, connect, the I O context, TCP and write, one header for each part we use, and then our framing. Log in is the whole conversation with the login server, in one function, and it takes the host, the port and the request. It makes its own I O context, which runs Asio's work, and then a socket, and a resolver. They live as long as the call, as a login is one request and one reply, and nothing has to keep running afterwards. Error is an error code, which Asio's calls fill in when something fails, like in our connection to the game server. The resolver turns the host's name into endpoints, which is a list of addresses, as a name can have several. The port goes in as text, as that's what the resolver takes for a service. If that worked, asio connect connects to the first one that answers. And if that worked, asio write writes the framed request. These are Asio's blocking calls, which wait until they're done. They're simpler than the asynchronous calls our connection to the game server uses, and fine here, as the client has nothing else to do until the reply is in. Then reader, our frame reader from episode three, and a buffer of a kilobyte. The loop goes on while there's no error, reading until a whole reply is in. If the reader has a whole message, we parse it as a LoginReply, and return it. A reply we can't parse is a data loss error, which is Abseil's code for data that arrived broken. If the reader has given up, say over a message that's too large, we return its status. Otherwise, read some waits for more bytes, as TCP may hand us the reply in pieces. We add them to the reader, and go round again. If anything failed, connecting, writing or reading, the loop ends, and we return an unavailable error, with Asio's message. So a login server that isn't running, or that closes the connection first, comes back as couldn't reach the login server. When the function returns, the socket closes, as the login server's job is done. There's no timeout here yet, so a login server that accepts the connection and doesn't answer would keep us waiting. In the protocol's CMakeLists, the library gets login client dot c c. It links with Asio, which it didn't need before, and with Abseil's status or and strings.
50:45 On the client's side, the screen where you log in gets a class of its own. Its header is login screen dot h, and after the guard, it includes array, for the text fields' buffers, and optional, for the request it may return. Then string, and utility, for std move, and the generated messages. LoginScreen is where the game starts, with a name and a password, to log in, or to create an account. Build builds this frame's login window, with ImGui, so it's called once a frame, as ImGui draws its windows again on each frame. It returns a request on the frame the player asks to log in or to create the account, and nothing the rest of the time. Show error keeps why the last try didn't work, to show under the buttons. It takes the string by value, and moves it in. The members are two arrays of chars, as ImGui's text fields type into a buffer that we own, and not into a std string. The username has room for sixteen characters, and one more for the zero at the end of a C string. The password has room for a hundred and twenty-eight, plus that zero, which matches the login server's limits. And error is the message to show, which show error sets. Then the source file, login screen dot c c, which includes imgui dot h after its header, as the screen is drawn with ImGui, like our menus. And icons font awesome six dot h, from rlImGui, for icons on its buttons, like the ones in our menus since episode two. In our namespace, build is the whole screen, in one function, and it starts by placing the window. Set next window pos puts the window in the middle of the main viewport, which is our window. ImGui cond Always does it on each frame, so it stays centred when the window is resized. The pivot of a half and a half puts the window's centre on that point, whatever the window's size. Begin opens it, titled Log in, and always auto resize sizes it to what's in it. No collapse and no move keep it where it is, as a login window has no reason to be dragged around. Is window appearing is true on the frame the window shows up, and set keyboard focus here gives the next field the keyboard. So we can type our name right away, without clicking. Input text is the name field, which writes into our username array, with its size, so that ImGui doesn't write past its end. The password field has two flags, and password shows asterisks, so that someone looking over your shoulder sees how long it is, and that's all. And enter returns true makes it return true when Enter is pressed in it, so that Enter logs in, like the button. Log in is true when the Log in button is pressed, or Enter, and the button's label starts with Font Awesome's sign-in arrow. Then a Create account button, on that line, next to it, with a person and a plus sign. If there's an error, text colored shows it in a soft red, after a warning triangle. And End closes the window, as ImGui needs one for each Begin. If neither button was pressed, there's no request, and we return nothing. Otherwise, we fill in a LoginRequest, with the username and the password from the arrays, and new account set for Create account. Then we fill the password's array with zeros, as the request has its own copy. And the comment over it says we don't keep the password once it's on its way. That way, the field is empty for the next try, and the password doesn't sit in the screen's memory for as long as the game runs. The old error goes too, as it was about the last try, and we return the request. In gui dot c c, we turn on ImGui's keyboard navigation, with nav enable keyboard. Tab then moves between the fields and the buttons, and Enter or Space presses a button. A login screen should work without the mouse, as most of us type a name, Tab, a password, and Enter.
54:40 The connection to the game server takes the token now, as a connection without one would get closed right away. Create takes it, along with the host and the port. And start, the private function that connects, takes it too. In connection dot c c, create hands the token on to start, which takes it as a third argument. Once it's connected, it makes a Hello, sets the token, and writes it, framed, with a blocking write, before it starts reading. So the Hello is the first message the server gets from us. If the write fails, the error code says why, and start returns an unavailable error, couldn't say hello to the server. Create hands that back to its caller, like the errors from connecting. The menu needs a way to say that the player wants to log out, so its build returns a bool now. We document that it returns true when the player picks Log out. A bool is enough here, and main decides what logging out means. In the source file, log out starts as false, and the Game menu gets a Log out item, above Quit, with the sign-out arrow for its icon. Menu item returns true on the frame it's clicked, which goes into log out, and build returns it at the end. The renderer gets clear, which fills the frame with the background colour, and draws no world, for behind the login screen. In renderer dot c c, draw now starts with a call to clear, in place of the lines that cleared the frame. Then clear, at the bottom of the file, gets the lines that draw used to start with. So while we're on the login screen, there's no 3D to draw, and the GPU has next to nothing to do. Then come the names over the players, in nameplates dot h, which includes the generated messages. It declares draw nameplates, which draws each player's name over its cube, from the world the server sent. It draws with ImGui, instead of raylib's text, so that the names use ImGui's vector font, and stay sharp at any size. So it has to be called between the GUI's begin and end. In the source file, we include imgui and raylib, then cfloat, for FLT MAX, and our camera. Draw nameplates goes through the players in the world it's given. First, it takes the background draw list, which draws behind ImGui's windows, and over our 3D scene. The foreground draw list would put the names over our windows too, such as the menus. The names are drawn with ImGui's current font, which is that vector font. Font size is a bit larger than the menus' text, at one point four times, so the names are easy to read over the board. For each player, Get world to screen, from raylib, turns a point in the world into a point on the screen, through our game camera. The point is one point nine up, above the cube, and above the marker over ours. Calc text size A measures the name at our font size, with no limit on the width, which is what FLT MAX is for. Corner is where the text starts, so that it's centred over the point, and sits just above it. Then we draw the name twice, first in a dark, slightly see-through colour, one pixel down and to the right, and then in white. The dark copy works as a shadow, so the name reads over light and dark tiles alike. And the client's CMakeLists gets the two new source files, login screen and nameplates.
58:00 The client's main changes the most, as the game now starts on a login screen, instead of a connection. It includes Abseil's str cat, for the messages on the login screen, then our login screen, nameplates, and the login client. In an unnamed namespace, the game port is seven seven seven seven, and the login port seven seven seven eight. Both servers run on one host for now, so the client takes one host, and has the two ports. Enter is the function that gets us into the game. It logs in on the login server, and then sends the token to the game server. It returns the connection to the game server, or a null pointer, with the reason on the login screen. Log in sends the request, and there are two layers to check. Reply or is OK when the login server answered at all. If it didn't, say the login server isn't running, the screen shows that we couldn't reach the server, with the status's message after it. Str cat joins the two into one line for the login screen. Then the reply's own ok says whether the login was accepted. If it wasn't, the screen shows the login server's error, such as wrong name or password, and we return. Otherwise, we log who we are, and connection create connects to the game server, with the token. If that fails, the screen says that we couldn't reach the server, and why. Otherwise, the connection comes out of its status or, and enter returns it. So a server that's down is an error message for the player, and the client keeps running, so that you can try again. In main, we don't connect before opening the window anymore, so those lines go, as without a token, there's nothing to connect with yet. The window opens first, still titled nanoMMO, then the GUI and the menu, as before, and a new login screen before the renderer. Connection is a unique pointer, empty until we've logged in, and again once we log out. Whether it's empty is how the loop tells which screen we're on, so there's no separate state to keep in step with it. In the loop, if we're connected, we poll the connection, as before. If the connection was lost, say the game server stopped, we reset it, which empties the pointer and takes us back to the login screen. The login screen shows a message, lost the connection to the server. Before, losing it ended the client with an error, and now you can log in again. While connected, the input and the world work as before, through the pointer now. We send our movement when it changes, and count the players online, to log it. Then set cubes hands the renderer what scene builds from the server's world. When drawing, the renderer draws the world once we're in it. Until then, it clears the frame, and the login screen sits on the background. Inside the GUI, while connected, we draw the nameplates over the world, and build the menu. If the menu says log out, resetting the connection closes it, and the game server saves where we were, as it does for any player who leaves. Otherwise, the login screen is built, and when it returns a request, enter turns it into a connection. We clear what we last sent and the players we counted, as they belonged to the old connection. The login blocks the frame for the seventy milliseconds or so that the login server takes to check the password. That's fine on a login screen, where nothing else is moving.
Logging in
1:01:14 Let's try it, with the login server and the game server running, along with MySQL and Redis. The client opens on the login screen, with nothing behind it. I type a name, alice, then Tab, and a password, which shows as asterisks. Tab twice gets to Create account, and Enter presses it. The login server creates the account, the client sends the token to the game server, and we're in. I walk to the right, and up a bit, and our name follows over our cube. Now I pick Game, then Log out, and the game server saves where we are. We're back on the login screen, with the name still filled in. I Tab to the password, type it in again, and press Enter. That's a login this time, not a new account, and we're back where we left off.
Bots with accounts
1:02:02 The game server needs a token now, so the bots have to log in too, like a player. In bot dot h, start takes the token, along with the game server's address. In bot dot c c, we include string, and the generated messages, for the Hello. Start's definition follows its declaration, with the token after the game server's address. The connect handler is a lambda that runs later, once the connection is made. So it captures a copy of the token, as the caller's string may be gone by then. Once connected, it makes a Hello, the first message the server expects. The comment over it explains: blocking is fine, a hello this small fits in the socket's buffer. The rest of the bot is asynchronous, and a blocking write could hold up all the bots on this thread. However, a socket that has just connected has an empty send buffer, so a message of under a hundred bytes fits in it, and the write returns right away. It sets the token, and writes it with asio write, before the first input, so the server sees it first. The write fills in the error code, and if it failed, the bot gives up, as it can't play without getting in. In the bot's main file, we include Abseil's status and status or, and our login client. The ports go into constants, like the client's, as the bots now talk to two servers. Log in is a function that logs a bot in, and returns its token, or why it couldn't. It fills in a login request, and the bots log in as bot one, bot two, and so on, all with one password. It tries to log in first, as from the second run on, the accounts exist. If the login server answered, and said no, it's the bot's first time, so it asks again with new account set. Then it checks both layers, like the client, and if there was no answer, the status goes back as it is, for the caller to log. If the login server said no again, it returns a permission denied error, with the login server's reason. Permission denied is Abseil's code for a request that's refused. Otherwise, it returns the token, and that way, the bots work on a fresh database and on one they've used before, with no setup step. The swarm needs the login server's host for each bot it adds. So its constructor takes it, as login, and moves it into a member. When it adds a bot, it works out the bot's number first, as it goes into the bot's name, and seeds the bot's random wandering, like before. Then log in gets the bot a token, and if it got one, the bot starts with it. Otherwise, say the login server is down, we log the bot's number, and the status that says why it couldn't get in. Either way, the swarm then carries on with the next bot. Each bot now waits for a password hash before it starts, on the swarm's thread, which slows the ramp down a little, and that's fine for a load test. At the bottom of the class, the new member, login, sits next to the game server's address. In main, the first argument is now the login server's host, and the second the game server's, both localhost by default. The game server's port comes from its constant, so the second argument is a host, and nothing more. And the log line says where the bots log in, as well as where they play. In compose dot yaml, the bots log in on login and play on server, the two services' names, and they wait for both to start.
1:05:24 Let's watch them, with the login server and the game server running at the bottom. I'm logging in with a client, so that we can see the board. Next to the servers, nanommo bot starts twenty bots, over six seconds. Each one tries to log in, gets refused, as it has no account yet, and creates one. The login server logs both, the refusal and the new account. Then the bot joins the game server with its token, and the game server logs its name. In the client, each bot's name shows over its cube as it arrives, and they wander off from the middle of the board. Next time these bots start, they'll log in right away, and carry on from where they were.
Docs
1:06:04 Last, the README, where hiredis joins the libraries CMake fetches for us, so it needs no install. Running the game now means starting MySQL and Redis with Docker Compose, then the login server, the game server and the client, each in a terminal of its own. The client's part explains the login screen: Create account the first time, Log in after that, and Game, Log out to go back to it. The section on accounts becomes one on accounts and logins, with the login server's port, the token in Redis, and how the game server loads your player. It also says that the password goes over the network as it's typed. That's fine on our own machine, and before anyone plays over the internet, the login server needs TLS. The tests now want nanommo Redis host as well, the list of images gets the login server's, and the bots' part says how each one logs in, and creates its account the first time.
Wrap-up
1:06:58 So, today logins got a server of their own, so that the slow work stays away from the tick. The login server hands each login over to the game server as a token in Redis, which works once, for thirty seconds. The game server takes the token out of Redis, and loads that player. The client opens on a login screen, and puts each player's name over its cube, and the game server saves where each player is, so you come back where you left off. The bots log in too, creating their accounts the first time. And the tests that need MySQL and Redis run against both, in containers, on my machine and in CI.
Playing it
1:08:34 Let me know in the comments what you'd like to see next. And if you want to follow along with the series, subscribe. I hope you find this useful, and thanks for watching.