Last time we got the login screen working β the client connects, negotiates a server, and lands on that iconic MU Online login prompt. But of course, entering credentials and hitting Enter still did nothing. Time to fix that.
As I promised in my previous article, we are going to tackle the login flow on the server side. Thanks again to the OpenMU guys, we can inspect the packets involved in this flow.
Of course this flow is simplified, omitting some details and only caring about the happy path.
The Login Packet#
If we inspect the MU Online protocol in OpenMU, as I mentioned previously they manage different versions for old version compatibility, and some packets have multiple variants. The Login request is a good example: there are 3 different packets for the login itself from the client. If we inspect the packet definitions we are going to find:
- C3 F1 01 - Login075
- C3 F1 01 - LoginShortPassword
- C3 F1 01 - LoginLongPassword
All these packets share the same type, code, and sub code. The first one, as the name implies, is for retro compatibility with old versions, so we are going to skip it. After testing a couple of times with the client I pointed to in the first article, it always sends LoginLongPassword regardless of the length or combination I use, so that is the one we are going to implement.
Maybe if you are curious and find this interesting, you can contribute to support the other packets!
So if we inspect the packet definition we are going to work with:
| Index | Length | Data Type | Value | Description |
|---|---|---|---|---|
| 0 | 1 | Byte | 0xC3 | Packet type |
| 1 | 1 | Byte | 60 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xF1 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x01 | Packet header - sub packet type identifier |
| 4 | 10 | Binary | Username; encrypted with XOR3 | |
| 14 | 20 | Binary | Password; encrypted with XOR3 | |
| 34 | 4 | IntegerBigEndian | TickCount | |
| 38 | 5 | Binary | ClientVersion | |
| 43 | 16 | Binary | ClientSerial |
As we can see, we have another encryption layer. The username and password fields are encrypted with XOR3, which as the name implies is the same XOR concept we discussed before but using a 3-byte key instead of a 32-byte one.
But wait, there's a catch. If you remember from the PacketTypes definition, the C3/C4 packets from Client -> Server arrive encrypted with the combination of Simple Modulus + XOR32. So before we can even get to the username and password, we have to peel back those outer encryption layers first.
The full decryption pipeline for a login request looks like this:
bytes (from client)
β
βΌ
[SimpleModulus decrypt] β recovers the XOR32'd intermediate
β
βΌ
[XOR32 decrypt] β recovers the plaintext packet
β
βΌ
[XOR3 decrypt] β recovers the username and password fields
β
βΌ
plaintext packet
Let's implement each layer.
XOR3: The Field Cipher#
XOR3 is the simplest of the three. It works exactly like XOR32 but with a 3-byte key, and crucially it does not use chaining, unlike XOR32, which we'll see next, each byte is fully independent:
E[i] = P[i] ^ KEY[i % 3]
So:
P0 P1 P2 P3 P4 P5 β plaintext
β β β β β β
K0 K1 K2 K0 K1 K2 β key cycling
β β β β β β
E0 E1 E2 E3 E4 E5 β ciphertext, each independent
Because there is no chaining, the same function works for both encrypt and decrypt:
const DEFAULT_XOR3_KEY: [u8; 3] = [0xFC, 0xCF, 0xAB];
fn apply_xor3_in_place(data: &mut [u8]) {
for (index, byte) in data.iter_mut().enumerate() {
*byte ^= DEFAULT_XOR3_KEY[index % 3]
}
}
pub fn encrypt_xor3(data: &mut [u8]) {
apply_xor3_in_place(data);
}
pub fn decrypt_xor3(data: &mut [u8]) {
apply_xor3_in_place(data);
}
XOR32: The CBC Style Cipher#
XOR32 works like regular XOR but with one extra ingredient that makes it CBC style: it uses the previous output byte as part of the next encryption. This chaining means a single-byte change corrupts all subsequent bytes.
pub fn encrypt_xor32(data: &mut [u8]) {
let header_size = header_size(data[0]);
for i in header_size + 1..data.len() {
data[i] = data[i] ^ data[i - 1] ^ XOR32_KEY[i % 32];
}
}
pub fn decrypt_xor32(data: &mut [u8]) {
let header_size = header_size(data[0]);
for i in (header_size + 1..data.len()).rev() {
data[i] = data[i] ^ data[i - 1] ^ XOR32_KEY[i % 32];
}
}
The loop mutates data in place, going forward. By iteration i, data[i-1] has already been overwritten by the previous iteration β it is the encrypted output, not the original plaintext.
i = h+1: data[i] = P1 ^ P0 ^ K1 β E1 (P0 = untouched header byte, acts as IV)
i = h+2: data[i] = P2 ^ E1 ^ K2 β E2 (E1, not P1!)
i = h+3: data[i] = P3 ^ E2 ^ K3 β E3
This is why decryption must go backwards. Going in reverse ensures data[i-1] is still the original ciphertext when we process byte i. If we decrypted forward, we'd use the already decrypted value wrong.
Plaintext is the original, readable data before encryption. Ciphertext is the transformed, obfuscated output after encryption.
Simple Modulus: The Block Cipher#
Simple Modulus is the heavy hitter in the MU Online protocol. Unlike the XOR ciphers, it is a proper block cipher: it operates on 8-byte plaintext blocks and expands each one into an 11-byte ciphertext block. It is used for all C3/C4 traffic.
The algorithm packs four 18-bit values into 9 bytes using a modular arithmetic transform, plus a 2-byte trailer with the block byte count and a checksum. OpenMU's creator has two great posts about it if you want to go deep: A closer look at the MU Online packet encryption and SimpleModulus revisited.
One detail worth highlighting: the cipher also includes a counter byte prepended to the first block. Both sides maintain a counter that increments with every encrypted packet. This prevents replay attacks you can't just re-send a captured packet because the counter won't match.
pub struct SimpleModulusKeys {
pub modulus_key: [u32; 4],
pub xor_key: [u32; 4],
pub operation_key: [u32; 4],
}
The keys are directional, so different key sets are used for serverβclient and clientβserver traffic. This is important: the server decrypts incoming client packets with SERVER_DECRYPT, and encrypts outgoing packets with SERVER_ENCRYPT. They are not interchangeable.
This is not modern cryptography. Itβs closer to obfuscation + integrity-ish check.
Upgrading the Codec#
Now that we have all three crypto primitives, the next question is where to apply them. The answer is the codec it already owns the raw byte stream, it knows the packet type from the first byte, and it processes every single packet that crosses the wire. It is the right place.
To support both the ConnectServer (which never encrypts) and the GameServer (which uses the full pipeline), we introduce an EncryptionMode:
#[derive(Debug, Clone)]
pub enum EncryptionMode {
/// No encryption β packets are framed and forwarded as-is (connect-server).
None,
/// Game-server mode.
///
/// Decode (client β server): SimpleModulus for C3/C4, then XOR32 for all types.
/// Encode (server β client): SimpleModulus for C3/C4 only; C1/C2 unchanged.
SimpleModulusPlusXOR32,
}
The codec becomes stateful, tracking counters for both directions:
pub struct PacketCodec {
max_packet_size: usize,
mode: EncryptionMode,
decrypt_counter: u8,
encrypt_counter: u8,
}
Decoding a packet from the client now goes through the two-stage pipeline:
EncryptionMode::SimpleModulusPlusXOR32 => {
// Stage 1 β SimpleModulus: decrypt C3/C4; C1/C2 pass through.
let after_sm: BytesMut = if raw[0] >= 0xC3 {
let decrypted = simple_modulus::decrypt(
raw.as_ref(),
&SERVER_DECRYPT,
&mut self.decrypt_counter,
)
.map_err(|e| ProtocolError::Decryption(e.to_string()))?;
BytesMut::from(decrypted.as_slice())
} else {
raw
};
// Stage 2 β XOR32: applied to all packet types.
let mut buf = after_sm;
decrypt_xor32(&mut buf);
buf.freeze()
}
And encoding a packet going to the client is symmetric:
EncryptionMode::SimpleModulusPlusXOR32 => {
if item.packet_type().is_encrypted() {
// C3/C4: apply SimpleModulus.
let encrypted = simple_modulus::encrypt(
item.as_slice(),
&SERVER_ENCRYPT,
&mut self.encrypt_counter,
);
dst.extend_from_slice(&encrypted);
} else {
// C1/C2: no encryption server-side.
dst.extend_from_slice(item.as_slice());
}
}
Each server just sets the mode in its config, the ConnectServer uses EncryptionMode::None, the GameServer uses EncryptionMode::SimpleModulusPlusXOR32. The codec handles everything else.
With the encryption layer in place, if we start our server and connect from the client, we can see the expected packet arriving:
2026-03-03T10:04:07.190561Z INFO mu_runtime: GameServer listening bind_addr=0.0.0.0:55901
2026-03-03T10:04:17.083234Z INFO mu_runtime: client connected peer=127.0.0.1:54092
2026-03-03T10:04:17.087529Z DEBUG game_server: client handler has started. peer=127.0.0.1:54092
2026-03-03T10:04:17.087798Z DEBUG game_server: sent hello packet peer_addr=127.0.0.1:54092
2026-03-03T10:04:20.680449Z DEBUG game_server: received packet packet=RawPacket(len=60, bytes=[C3, 3C, F1, 01 ....))
The C3 - F1 - 01 packet shows up, decrypted and valid. That is our login request, ready to handle.
Domain: Accounts and Characters#
We are receiving the packet correctly, but our GameServer has no logic for routing or handling it yet. Before we get to login handling, we need something fundamental: the domain model for the game itself.
We have no concept of Account or Character yet, and no database. So let's build that foundation now.
We are going to introduce a new crate called mu-game. This is where our core domain entities and game logic will be.
Core Entities#
// crates/mu-game/src/iam/account.rs
pub struct Account {
pub id: AccountId,
pub username: Username,
password_hash: String,
pub ban_status: BanStatus,
pub last_login_at: Option<DateTime<Utc>>,
}
// crates/mu-game/src/character/character.rs
pub struct Character {
pub id: CharacterId,
pub account_id: AccountId,
pub slot: CharacterSlot,
pub name: CharacterName,
pub class: CharacterClass,
pub level: CharacterLevel,
experience: u64,
pub spawn: SpawnPoint,
pub hero_state: HeroState,
}
Notice that password_hash is private. The only way to use it is through account.password_hash(), a method that is intentionally scoped to the authentication use case. The domain controls access to sensitive fields.
BanStatus is also worth a look:
pub enum BanStatus {
Active,
Banned,
TempBanned { until: DateTime<Utc> },
}
And can_authenticate encodes the business rule directly in the entity:
pub fn can_authenticate(&self) -> Result<(), AccountError> {
match self.ban_status {
BanStatus::Active => Ok(()),
BanStatus::Banned => Err(AccountError::PermanentlyBanned),
BanStatus::TempBanned { until } => {
if until > Utc::now() {
Err(AccountError::TemporarilyBanned { until })
} else {
Ok(()) // ban expired
}
}
}
}
The logic lives where it belongs: in the domain, not scattered across handlers.
Repository Ports#
Once we have our entities, we define the contracts for how we interact with persistence. In Rust, we do this with traits:
// crates/mu-game/src/iam/ports.rs
#[async_trait]
pub trait AccountRepository: Send + Sync {
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<Account>, InfrastructureError>;
}
// crates/mu-game/src/character/ports.rs
#[async_trait]
pub trait CharacterRepository: Send + Sync {
async fn find_all_by_account_id(
&self,
account_id: AccountId,
) -> Result<Vec<Character>, InfrastructureError>;
async fn find_by_name_and_account(
&self,
account_id: AccountId,
name: &CharacterName,
) -> Result<Option<Character>, InfrastructureError>;
}
These traits are the boundary between our domain and the outside world. The mu-game crate knows nothing about Postgres, Redis, or anything else, it only knows these contracts. This is often called the Ports and Adapters pattern, and it gives us a clean separation that makes testing and swapping implementations straightforward.
Services#
With our entities and ports in place, the domain services become simple. Here is the AuthService:
pub struct AuthService<R, S> {
account_repo: Arc<R>,
session_registry: Arc<S>,
}
impl<R, S> AuthService<R, S>
where
R: AccountRepository,
S: AccountSessionRegistry,
{
pub async fn login(
&self,
username: &Username,
password: &str,
) -> Result<AccountId, AuthServiceError> {
let account = self
.account_repo
.find_by_username(username)
.await?
.ok_or(AuthServiceError::InvalidCredentials)?;
account.can_authenticate()?;
let matches = bcrypt::verify(password, account.password_hash())
.map_err(|_| AuthServiceError::PasswordVerificationFailed)?;
if !matches {
return Err(AuthServiceError::InvalidCredentials);
}
let registered = self
.session_registry
.register(account.id)
.await
.map_err(|e| InfrastructureError::CacheOperationFailed(e.to_string()))?;
if !registered {
return Err(AuthServiceError::DuplicateSession);
}
Ok(account.id)
}
}
Using generics here for R and S might look like extra ceremony, but it is doing something important. AuthService<R, S> does not know or care what R is as long as it implements AccountRepository. This means:
- In production we pass
PostgresAccountRepo. - In tests we pass a simple in-memory fake.
- No dynamic dispatch, no
Box<dyn Trait>overhead, the compiler generates a specialized implementation for each concrete type used.
The same logic applies to S: AccountSessionRegistry, which is how we prevent duplicate logins. Before completing a login, we try to register the account's session. If it is already registered, someone else is logged in with those credentials and we return DuplicateSession. The registry itself is simple:
pub struct InMemorySessionRegistry {
active: Mutex<HashSet<AccountId>>,
}
#[async_trait]
impl AccountSessionRegistry for InMemorySessionRegistry {
async fn register(&self, account_id: AccountId) -> Result<bool, InfrastructureError> {
let mut active = self.active.lock().await;
Ok(active.insert(account_id)) // returns false if already present
}
async fn unregister(&self, account_id: AccountId) -> Result<(), InfrastructureError> {
self.active.lock().await.remove(&account_id);
Ok(())
}
}
HashSet::insert returns false if the element was already in the set, exactly the behavior we need.
As we can see currently we are implementing just a simple InMemorySessionRegistry but we can use something like Redis to persist the account sessions across multiple hosts.
The Persistence Layer#
With our domain and ports defined, we now create a new crate: mu-db. This crate contains the Postgres implementations of our repository traits and nothing else. It is the adapter side of our Ports and Adapters pattern.
We start with a wrapper around sqlx::PgPool:
pub struct Postgres {
pool: PgPool,
}
impl Postgres {
pub async fn new(url: &str) -> Result<Postgres, DBError> {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(url)
.await?;
Ok(Self { pool })
}
pub async fn run_migrations(&self) -> Result<(), DBError> {
sqlx::migrate!("./migrations").run(&self.pool).await?;
Ok(())
}
pub fn account_repo(&self) -> PostgresAccountRepo {
PostgresAccountRepo::new(self.pool.clone())
}
pub fn character_repo(&self) -> PostgresCharacterRepo {
PostgresCharacterRepo::new(self.pool.clone())
}
}
Wrapping sqlx::PgPool like this gives us a single place to configure the pool and run migrations. The account_repo() and character_repo() factory methods keep pool sharing simple PgPool is already an Arc backed pool internally, so cloning it is cheap.
We ported a minimal set of migrations from OpenMU to cover the account and character tables needed for this flow.
The repository implementation maps between our DB rows and domain entities. We use a private AccountRow struct for the raw query result and a From implementation to do the conversion:
#[derive(Debug)]
struct AccountRow {
id: i64,
username: String,
password_hash: String,
is_banned: bool,
banned_until: Option<DateTime<Utc>>,
last_login_at: Option<DateTime<Utc>>,
}
impl From<AccountRow> for Account {
fn from(row: AccountRow) -> Self {
let ban_status = if row.is_banned {
match row.banned_until {
Some(until) if until > Utc::now() => BanStatus::TempBanned { until },
_ => BanStatus::Banned,
}
} else {
BanStatus::Active
};
Account::new(
AccountId(row.id),
Username::new(&row.username)
.expect("DB VARCHAR(10) constraint guarantees a valid login name"),
row.password_hash,
ban_status,
row.last_login_at,
)
}
}
And then the repository itself is straightforward:
#[async_trait]
impl AccountRepository for PostgresAccountRepo {
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<Account>, InfrastructureError> {
let row = sqlx::query_as!(
AccountRow,
"SELECT id, username, password_hash, is_banned, banned_until, last_login_at
FROM accounts WHERE username = $1",
username.as_str()
)
.fetch_optional(&self.pool)
.await
.map_err(|e| InfrastructureError::DbQueryFailed(e.to_string()))?;
Ok(row.map(Account::from))
}
}
The CharacterRepository follows the same pattern, a CharacterRow that maps to our Character domain entity. One interesting detail there: the class and hero_state fields come out of the database as integers, and if the value doesn't match a known enum variant (maybe the DB has data from an old version), we default gracefully and log a warning rather than crashing.
Thanks to the OpenMU team's migrations and well structured entities, we could port a solid foundation quickly rather than defining the schema from scratch.
The Session State Machine#
Now we have everything we need: crypto, domain, persistence. The last piece is routing packets to the right handler based on where the client is in the connection lifecycle.
A client goes through distinct states:
[Connected] ββββ login OK βββββββΊ [Authenticated] ββββ select character βββΊ [InGame]
β β
βββ bad creds, keep trying βββ logout / disconnect
We model this explicitly with a SessionState enum:
pub enum SessionState {
Connected,
Authenticated {
account_id: AccountId,
},
InGame {
account_id: AccountId,
character: Character,
},
}
Each state has its own handler module. The main session loop drives the state machine:
async fn run_loop(
stream: &mut PacketStream,
peer: &SocketAddr,
state: &Arc<GameState>,
logged_account: &mut Option<AccountId>,
) -> Result<()> {
let mut session = SessionState::Connected;
loop {
let packet = match stream.recv().await {
Some(Ok(p)) => p,
Some(Err(e)) => {
warn!(%peer, error = %e, "packet error, closing session");
break;
}
None => {
debug!(%peer, "client disconnected");
break;
}
};
session = match session {
SessionState::Connected => connected::handle(stream, packet, state).await?,
SessionState::Authenticated { account_id } => {
authenticated::handle(stream, packet, state, account_id).await?
}
SessionState::InGame { account_id, character } => {
// future: game world logic
SessionState::InGame { account_id, character }
}
};
// Track logged account for cleanup on disconnect.
match &session {
SessionState::Authenticated { account_id } => *logged_account = Some(*account_id),
SessionState::InGame { account_id, .. } => *logged_account = Some(*account_id),
SessionState::Connected => {}
}
}
Ok(())
}
The outer run function wraps this loop to guarantee cleanup, if the client disconnects (for any reason), we call logout to unregister the session:
pub async fn run(mut stream: PacketStream, peer: SocketAddr, state: Arc<GameState>) -> Result<()> {
let mut logged_account: Option<AccountId> = None;
let result = run_loop(&mut stream, &peer, &state, &mut logged_account).await;
if let Some(account_id) = logged_account {
state.auth_service.logout(account_id).await;
}
result
}
This ensures we never leave stale sessions in the registry, even if the client crashes or drops the connection mid-game.
Handling the Connected State#
In the Connected state, the only packet we accept is the login request (C3 F1 01). Everything else is ignored with a warning.
The login handler decrypts the username and password fields using XOR3, calls AuthService::login, and builds the response:
pub(crate) struct LoginRequest {
username: Username,
password: String,
}
impl TryFrom<RawPacket> for LoginRequest {
type Error = anyhow::Error;
fn try_from(packet: RawPacket) -> Result<Self> {
let data = packet.as_slice();
let raw_username = decrypt_field(&data[4..14])?;
let raw_password = decrypt_field(&data[14..24])?;
let username = Username::new(&raw_username).map_err(|_| ProtocolError::Malformed)?;
Ok(LoginRequest { username, password: raw_password })
}
}
fn decrypt_field(raw: &[u8]) -> Result<String, ProtocolError> {
let mut buf = raw.to_vec();
decrypt_xor3(&mut buf);
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
std::str::from_utf8(&buf[..end])
.map(str::to_string)
.map_err(|_| ProtocolError::Malformed)
}
At this point the codec has already handled the SimpleModulus + XOR32 decryption, so we are working with the plaintext packet. We just need to strip the field level XOR3 from the username and password bytes.
The login response maps each error case to the wire byte the client expects:
fn build_login_response(login_result: &Result<AccountId, AuthServiceError>) -> RawPacket {
let code = match login_result {
Ok(_) => 0x01, // success
Err(AuthServiceError::InvalidCredentials) => 0x00,
Err(AuthServiceError::DuplicateSession) => 0x02,
Err(AuthServiceError::Domain(AccountError::PermanentlyBanned)) => 0x03,
Err(AuthServiceError::Domain(AccountError::TemporarilyBanned { .. })) => 0x06,
Err(_) => 0x00,
};
// C1-F1-01 LoginResponse (5 bytes)
let mut buf = BytesMut::with_capacity(5);
buf.put_u8(C1);
buf.put_u8(0x05);
buf.put_u8(0xF1);
buf.put_u8(0x01);
buf.put_u8(code);
RawPacket::try_new(buf.freeze()).expect("login response is always valid")
}
Handling the Authenticated State#
Once authenticated, the client can request the character list and then select a character. The Authenticated state handles two packets:
C1 F3 00β Character List RequestC1 F3 03β Select Character
The character list response (C1-F3-00 CharacterListExtended) follows a specific Season 6 wire layout: an 8-byte header followed by 44-byte entries, one per character. Each entry includes the slot index, name (10 bytes, null-padded), level, status flags, and a 27-byte appearance block encoding the character class, pose, and equipped items.
After the client selects a character, we send two packets in sequence:
C3-F3-03 CharacterInformation:72 bytes of character stats (position, map, experience, combat stats, hero state, money). Most of these are hardcoded to placeholder values for now, we'll fill them in as the stat system takes shape in a future article.C3-1C MapChanged:8 bytes that tell the client to switch to the character's map.
Together these two packets transition the client from the login screen into the game world.
async fn handle_select_character(...) -> Result<SessionState> {
// ... parse character name from packet bytes [4..14] ...
let character = state.character_service.select_character(account_id, &char_name).await?;
stream.send(build_character_stats(&character)).await?;
stream.send(build_map_changed(&character)).await?;
Ok(SessionState::InGame { account_id, character })
}
Once the client receives these two packets, it loads the game world. The session transitions to InGame and the login flow is complete.
Putting It All Together#
The workspace now looks like this:
ββββββββββββββββββββββ βββββββββββββββββββββββ
β connect-server β β game-server β
β β β session, state β
ββββββββββ¬ββββββββββββ ββββββββββ¬βββββββββββββ
β β
βββββββββββββ¬βββββββββββββ
β
βββββββββββββΌβββββββββββββ
β mu-runtime β
β Server, PacketStream β
βββββββββββββ¬βββββββββββββ
β
βββββββββββββΌβββββββββββββ
β mu-protocol β
β Packet, PacketCodec, β
β crypto (SM, XOR32, β
β XOR3), errors β
βββββββββββββ¬βββββββββββββ
β
βββββββββββββΌβββββββββββββ βββββββββββββββββββββββββ
β mu-game β β mu-db β
β Entities, Ports, ββββββ PostgresAccountRepo β
β Services β β PostgresCharacterRepoβ
ββββββββββββββββββββββββββ βββββββββββββββββββββββββ
Each layer has a clear responsibility. mu-protocol handles the wire format and crypto. mu-runtime handles the TCP loop and timeouts. mu-game owns the domain model. mu-db implements the persistence ports.
The GameState ties the concrete implementations together and gets shared (via Arc) across every client handler:
pub struct GameState {
pub auth_service: AuthService<PostgresAccountRepo, InMemorySessionRegistry>,
pub character_service: CharacterService<PostgresCharacterRepo>,
}
And in main, we wire everything up:
let db = Postgres::new(&db_url).await?;
db.run_migrations().await?;
let sessions = InMemorySessionRegistry::new();
let game_state = Arc::new(GameState::new(
db.account_repo(),
sessions,
db.character_repo(),
));
server
.run_tcp_listener(move |stream, peer_addr| {
let state = Arc::clone(&game_state);
async move { handle_client(stream, peer_addr, state).await }
})
.await
The Result#
With all of this in place, we now have a working login flow from start to finish. A client can connect, authenticate with real credentials from a Postgres database, see their character list, select a character, and enter the game world.
That said, there is still a lot to do. The InGame state is essentially a stub right now, we receive packets but don't handle them yet. The stat system is hardcoded to placeholder values. The session registry is in-memory and will not survive a server restart and many others. These are all things we'll tackle as the series progresses.
I'm not 100% sure what the next article will cover yet, but two things are already calling for attention:
- The
GameServerhandles a much larger and more diverse set of packets than theConnectServer. Rust's pattern matching is great, but a single massive enum for every possible game packet is not the answer. We'll need a better routing strategy. - The way we are currently parsing and building packets is functional but rough. There is room to make this more structured and ergonomic.
All the logic and implementation here is minimized for scope. There is plenty to improve and different ways to approach many of these problems that's part of the fun as the series advances, what we have today maybe wont fit tomorrow after a particular feature or mechanism, but for now it is ok.
Seeing a real login succeed with a server I wrote from scratch was genuinely satisfying.
All the code can be found in the repository. Feel free to explore, experiment, and leave comments!



