mido

thinking so ur dog doesnt have to

I like making little games with friends :] Mostly shit posting and vibing.


The work week comes to a close, a cursed man toils away at his cursed project.

I was graced with a lovely little youtube video demonstrating a type oriented state pattern.. type states, by the Lets Get Rusty channel. Link here. I've seen them before in other languages and not had much use in the contexts I was working in, but as I was watching this video I realized it'd suit a problem I was having nicely.

One of the most important structs in the library right now is the GameConnection struct, it contains the minimal necessary state to accomplish a large set of the objectives outlined in my previous cohost post on this.

A struggle I've had with it in the implementation so far is that while it feels like the bare minimum, it's still getting big:

game_connection.rs
pub struct GameConnection { // stream organization system_stream: Option<Box<GameStream>>, // system stream is enabled by configure_system_stream streams: [Option<Box<GameStream>>; 31], /// TODO: not sure we need to persist this, it really is only /// useful for the initial connection in my current conception. connection_slot_id: u64, join_challenge: [u8; 1024], // messages that the ingress has tee'd up for us to process recv_tx: mpsc::UnboundedSender<Vec<u8>>, // whether or not we're in charge of some responsibilities, such as // key rotations. is_authority: bool, // symmetric encryption state management /// # Blue/Green overview /// /// During key rotations we need to support both ciphers for a very short /// period of time, so we do a green/blue strategy. At least green is /// required to even build the GameConnection, but imediately the server /// should give us keys to build the blue cipher. Whenever both parties /// agree that one cipher is fully deprecated, keys will immediately be /// emitted along the system stream (0) to build the now-empty cipher. primary_cipher: PrimaryCipher, cipher_green_encryptions: u16, cipher_green: Option<ChaCha20Poly1305>, cipher_blue_encryptions: u16, cipher_blue: Option<ChaCha20Poly1305>, /// only meant to be used for sending! udp_send: Arc<UdpSocket>, /// on a server: initially [None] on servers as we need to wait for the client /// to finalize the join handshake. /// /// on a client: should be set because by the time we're joining the server /// the server has told us which [SocketAddr] to connect to. remote_addr: Option<SocketAddr>, }

Easily 1/3rd of this bloat is because because GameConnection broadly has two states, "pending" and "active". Some of these fields are important for pending state but not active, and vice versa, while some (like at least 1 cipher, and the Arc<UdpSocket>) are shared.

Now, this isn't so bad, but there's another problem: I have even more methods for interacting with this data! Let's look at just the signatures of the current impl GameConnection:

    pub fn create(..)
    pub fn set_remote_addr(..)
    pub fn configure_system_stream(..)
    pub fn create_stream(..)
    pub async fn do_client_join_handshake(..)
    async fn decrypt_and_route(..)
    pub async fn send(..)
    pub fn handle_encrypted(..)
    pub fn try_envelope_decrypt(..)
    pub fn encrypt_and_envelope(..)
    fn decrypt_envelope(..)
    pub fn test_join_challenge(..)
    fn get_primary_cipher(..)
    fn get_ordered_ciphers(..)
    pub fn get_connection_id(..)

I could just make completely orthogonal PendingConnection ActiveConnection structs, and I might go back and do that in the near future, but I wanted to explore this pattern since it would represent a less dramatic refactor.

Let's tack on the type state pattern:

pub struct Pending {}

pub struct Active {}

pub struct GameConnection<State = Pending> {
    _state: std::marker::PhantomData<State>,

If you're not familiar and don't feel like watching the nice little video I linked at the beginning, the basic gist is that Rust will let us interact with the same struct via two distinct trait types if we take advantage of Rust's generics and the special std::marker::PhantomData<T> helper, which combine to let us have these distinct traits/interfaces at compile time, but pay no real runtime cost for them.

With this, I can now take that previous huge blob of a impl GameConnection { and turn it into separate implementations structured around the behavior of the "pending" and "active" states, as well as any shared behavior:

impl GameConnection<Pending> {
    /// this will perform a few small runtime checks
    /// and validations and shift us over to
    /// the Active state
    pub fn promote(..) -> GameConnection<Active> {..}

    pub fn try_envelope_decrypt(..)
    pub fn set_remote_addr(..)
    pub fn configure_system_stream(..)
    pub async fn do_client_join_handshake(..)
    pub fn test_join_challenge(..)
}

impl GameConnection<Active> {
    pub fn create_stream(..)
    pub fn get_remote_addr(..)
    pub async fn send(..)
    async fn decrypt_and_route(..)
}

impl<State> GameConnection<State> {
    pub fn handle_encrypted(..)
    pub fn encrypt_and_envelope(..)
    fn decrypt_envelope(..)
    fn get_primary_cipher(..)
    fn get_ordered_ciphers(..)
    pub fn get_connection_id(..)
}

impl GameConnection {
    pub fn create(..)
}

I may come back with a hatchet if it bloats much more and just completely re-approach this. So far this project has had a decent pre-planning charter that keeps me focused, but like many complex projects you sometimes have to try things out to realize a better path.


You must log in to comment.