I have no idea what to call these projects. I’ve spent over a decade working under the title “Project Universe”, which means I have at least 30 different ZIP packages, GitHub repos, and random files lying around with some variation of that name. In order to make as clean a break as I can while still retaining the core concepts (and math work that I spent a lot of time on) I opted to call this attempt “Project Galaxy”.
I needed authentication in place because Google’s Firestore serverless NoSQL database requires that access rules be locked down after an initial free-form trial period. If I want to push ahead and work with data, I need to be able to authenticate a user. Firebase, the overarching platform of which Firestore is a part, handles that very well for people like me who would rather jump through a burning plate-glass window covered in spiders than roll another authentication system.
With authentication in place I realized that I’d need a structure representing a character which would hold information needed to start the game proper. The character holds information on where the player was last time she was in the game, how much money she has, and what kind of ship and cargo she’s purchased. I could fake it during the initial build phase, but as I’d need to get characters into the project at some point, why not start there and get the scaffolding up and running?
So, here’s how this works so far.
Account Creation and Login
The first step would be to create an account, but I’ve omitted that from the graph because it’s assumed that in order to log in, a user would have to have taken that step.
Logging in is handled by Firebase. A user can choose one of two options: email and password, or OAuth through a third party provider like Google, Twitter, or Facebook. Right now, the framework allows for email and password as OAuth providers require developer accounts, approvals, dashboard work, etc. that isn’t worthwhile at this point in the project.
All operations return some kind of data transport object, which is a wrapper that contains a status property, a friendly message and a technical message property, and a “data bucket” property. This construct allows me to pass flags, messages, and relevant data around.
I’ve abbreviated the character process representation in the flowchart above, but I have four steps in place right now.
As of this moment, a character is basically a name. There’s some internal info, but to create a character all a user would have to do is provide a name to be known by. The server creates a character object which is stored in the Firestore characters collection. I have it on my task list to validate names to disallow dupes and to pass them through a filter to keep out the questionable choices, but for now, go ahead and live your best life, B1gDic|<420!
Once the character has been created a data transport object is returned to the client. The data bucket property contains a list of all characters associated with this player’s account ID.
The character list is loaded first thing when the user hits the character web component. If a player has no characters, she’ll see “No characters”, but if she does, then she’ll get a list of them. Aside from the initial query, character lists are returned at the end of character creation and character deletion.
Each displayed character row contains two links: delete and play.
Deletion is always one of those QoL tasks that developers need to deal with in a way that protects users from themselves. In this case, the user will get a popup asking if he really wants to delete the character, and if so — POOF — the character record is removed.
I am still going back and forth about this, though. Do I really delete the record, or do I flag the record so that it cannot be seen? Depending on how much admin stuff needs to be done, such as linking deep history to a character record and such, it might make sense to keep the record around, but as Firestore charges by the transmission among other actions, keeping the data store as small as possible might be more (financially) advantageous.
Play is currently under development, and is one of the more complex operations so far.
With the JWT in hand, a user selecting a character to play will transmit the JWT and the selected character ID to the server. The server will then create a session payload. “Session” is thrown around a lot when dealing with Colyseus, which has it’s own idea of a “session”, but in this case my own session links the user account (found via parsing the JWT at the Firebase-method level) to the selected character and stamps it with a login date and time, a flag indicating that this is the character that is “logged in”, and other data. The session object is stored in a Firestore collection, and the session object is then returned to the client.
The client at this point will operate entirely through the session ID. One of the flows I have been struggling with has been how to pass identifiers between the client and the server in a way that secures information and prevents malicious actors from “jumping the line” to access content they shouldn’t. The concept of an “authoritative server” handles some of this, such as by checking to ensure that the next sector a player wants to move to is actually accessible from the sector the player is currently in, so I figured that by sending a session ID alongside the JWT I will have two elements which can be “expired” in the event the system needs to eject a session for questionable behavior. The session ID can be used to look up both the account ID (which can be compared to the payload of the JWT) and the current character ID without having to pass either between client and server.
The kicker is that once the client receives the session information, the application switches from pretty standard web API access to the Colyseus web sockets communication model. The receiving method on the client will redirect the user to the “play” React component, sending the session ID along for the ride.
I might have mentioned this in previous posts, but Colyseus operates on the concept of “game rooms”. Each room could be thought of as a level in a platformer game, where everything in the room is specific to that room. In order to get data into a room, it needs to be loaded when the room is created, and saved out when the room is destroyed. Colyseus has events which handle these events for the room itself, and also offers two events for players: onJoin and onLeave.
When a player lands on the React component that connects to Colyseus, the session ID will be passed into the server’s onJoin handler. Inside the room, the session ID will query the database to get everything it needs to “run” the player: which sector and station they were last at, their wallet amount, avatar, and ship and cargo. It will disregard any data it doesn’t immediately need so the system doesn’t simply download the entire database. That character data will be loaded through the Colyseus room state, which persists for as long as that room is in operation. Each character who enters the room has his or her data loaded into various collections which we can access using a Colyseus client reference (which is very client-server-technobabbly).
When the player takes action, the server will be updating the session data in the database. This is another thing I’m waffling on. On one hand, knowing that the player is still playing is important. Colyseus has it’s own timeout function and when handled correctly can allow for graceful disconnect and cleanup, so it might be enough to rely on that because on the other hand, I’m charged by Google for every access of the database. My development and testing remains well within the limits of their “free as in beer” plan, but should this ever get released, and should it ever get people actually playing it, I don’t know what kind of traffic a constant stream of updates would generate.
When a player disconnects, we use the server’s onLeave handler to take whatever info we know about that character — where they are, what they have lost or acquired during their session, and how much money they have left — and we save it to the database. Know, however, that because each game room is its own distinct entity, onLeave doesn’t just encompass a player shutting down his browser; it also means that the player has decided to move to another sector or dock at a space station, which the server understands as “this player wants to move to another room”.
By saving the data via onLeave and then re-loading it in follow-up call to onJoin, we once again pass the session ID and JWT to the server, load up the character info, and voila! They are now in a new sector/room or station/room.
I have verified that all of this “works”. I put that in scare-quotes because while it does work, it’s not optimized, clean, or easy to follow. Both client and server are going to need some major refactoring in addition to building-out to accommodate new information that I might need the further I progress.
The next step, then, is to use the data on the character to route them to the correct Colyseus sector-room. I have defined one room to handle all sectors since each sector behaves the same and contains the same data structures, differing only in content. In the examples I have studied, loading room data is handled in the server’s onCreate handler, which spins up a room the first time a client attempts to connect, while the character information is loaded in the subsequent onJoin handler. To follow this, I’ll need to get the character record before sending the player into a room so that I know which sector the character should load.