Rambling about C# 9.0, games and networking

Back at it once again! Rambling about stuff, and not really even trying to make a point. I'm warning you. What else are you supposed to do when your train is late by several hours?

* * *

I recently got an urge to test out the new C# 9.0 language features and see if they could make it easier to write state-centric games (like USG:R I blogged about earlier). TypeScript is nice, but nothing really beats C#, so this is quite exciting. With the addition of data classes (now called just records), the promise was that it would be easier to use immutability, which is one of the core principles with React and Redux.

And things did work. And they worked just like with Redux. But that's the problem. The Redux way is that each reducer produces the new state by creating a new instance of the state with the changed part replaced: return {...oldState, counter: oldState.counter + 1}. Very simple, and the reducers stay pure by not mutating the input values. And each state object can be safely stored in case there's need to do some kind of time travel debugging or state replaying, or anything like that. But the very huge downside of this is that it gets very messy if the state hierarchy is any deeper.

The alternative is something like ImmerJS, where the reducers are allowed to mutate the state directly: state.very.deep.someArray.push(value). The library takes care of efficiently making a copy of the state object as needed, meaning that if something isn't modified, it also doesn't need to be copied either. So that 10 000 element array isn't copied each time the counter is incremented. And it works great. The code is A LOT simpler, and the performance penalty isn't actually that huge thanks to the on-demand functionality.

Imagine my dissapointment when I realized this. I'd been waiting for records for maybe few years already, before even hearing about ImmerJS. And then when I finally get them, the problem I wanted them to solve had changed... But that isn't to say they aren't a good addition, and can't be used elsewhere. But state stuff was what I was really waiting them for. For carrying simpler things - espicially events - they are great.

* * *

But back to the state games. The dream is to be able to write them in C# and surpass the productivity of React and Redux. So, what do? Why not just mutate the global state like all the other normal games. The state can be explicitly copied if need be. Well. That is a good point. Can't really counter it. (There's also some very React-like bindings for Blazor and MAUI, solving the other part of the equation).

Except if the domain is network games! If the game can be constructed in such a way that there isn't too many state changes (and preferably the state itself is small), it would be trivial to transmit those changed states over the network, and render the state in the client. And the big thing: what if we took a page out of the ImmerJS playbook, and replaced the state object on the server with one that keeps track of the concrete changes made to it? Then just the changes themselves could them be transmitted over the network. No need for expensive copying, and no need to transmit the whole state. Also no need to "manually" compute the deltas, as the data structure itself does it. It sounds so cool!

Although realisticly I'm not sure if this has ever been a problem. The states in most games should be relatively small anyway, and especially with the hardware today the deltas can be computed with just brute force by even relatively simple algorithms. Just like games like Quake 3 have been doing for ages. I highly doubt it will at any point really be that prohibitely expensive. But one can always dream of doing things better. Especially when it comes to cloud-scale and IoT, where every cycle counts.

Speaking of which. Related to the above, I've been building a general purpose framework for state based applications. I'm not sure if I'll ever really get to using it, but it's been nice coding something of my own once in a while. And using Redis always evokes warm fussy feelings :) If executed well, a framework like that might have some money-making potential. Or just be a nice tool to easily create some multiplayer games. Or just research, as always. That's the most important point.

This framework I'm making consists of an ASP.NET Core SignalR WobSocket gateway that handles user registrations and authentication (with EdDSA JWTs! Although with an unsafe curve, because libraries...) and then connects them to sessions that are persisted on a sharded Redis pool. The clients receive state updates and can send inputs to a session's input queue. They can also optionally see the other clients participating in the session. The session itself is managed by server applications (for the lack of a better name). They connect the the session's Redis server and take ownership over sessions assigned to them (or otherwise delegated upon them). Then they simply take inputs from the input queue and mutate the state based on the input and the current state (just like Redux), and finally publish the new state to the clients (in the future hopefully with a delta). They can also inject their own events (such as time passing) to the event queue. If a client needs to reconnect, it can simply read the current state from Redis. And if the server crashes or needs to restart, the state and the inputs are persisted in Redis, resulting in no data loss. Well, of course as long as Redis stays alive.

The key point is that this kind of architecture enables laughably easy way to upgrade the server code, as there isn't really any downside to killing the server application and then having it restart with changed code. When it starts it just reads the current state and starts processing inputs like before. Of course this could just be achieved by co-operatively shutting down the old server instance and saving the state before it closes. But that isn't any fun. And it would be extra effort to support taking over individual sessions. With that system it comes for free. Not that it would really be of that much use, but how cool is that in theory! (Each session has a host serial, and only the application server holding the most recent serial can make changes to the state. When taking over a session the serial is just incremented, invalidating the old server.)

Anyway. What I also like about this is how scalable it is. There isn't any application-specific code on the gateway and as all the clients communicate only via sessions, and one client is connected to only one session at a time. This means that it is easy to spin up as many instances of the gateway as is needed, and other than the Redis session backplane there really isn't any cross-communication between the gateways. Except of course the user registration and login. Also, the sessions themselves don't need to talk to other sessions and are completely self-contained except for the orchestration (what server instance starts serving a new session). This means that the Redis side of things can also be easily scaled by sharding the sessions by their id. And further, the server application nodes themselves can also be infinitely scaled. So should something happen and the games running on that framework became hugely popular, it is no problem architecture-wise to just spin up some more instances.

The only problem is how reliant this is about Redis. The initial prototype I've been building makes excessive use of Redis lua scripting and combines dozen operations in things like adding an input to the queue. It should be extremely unlikely, but should something go sideways during the execution on such a script, the recovery won't necessarily be easy. Although most of those operations are about checking consistency and updating expiries anyway, so it really isn't a problem. But what I am really interested about is the performance. I'm really curious to see what kind of performance charasteristics this kind of system has. Also, scalability is nice and all, but as I've talked about before, single-node performance is what is really the name of the game. Less nodes, less cost. This isn't really going to end up in that category ':D But hey, we have to remember that all's of course not that straightforward, as development time is also a factor. For once...

And this thing here is actually really simple and easy. I hope to prove it. At least to myself :d After that I'd like to test some other topologies. Dwell on all the missed performance and the system's dependency on Redis. A loose coupling between the gateway and the servers, but an even tighter coupling decoupling them.

But at least it'll be fun.

No comments: