DEV Community

Ygor Castor
Ygor Castor

Posted on

Building a Ragnarok Online Server in Elixir - Part 2

For part 1 (on medium, sorry).

Well, we now have a basic login server in place, so we have a loooooong way to go. In this part, we will focus on 3 aspects:

  1. Persistence
  2. Character Server
  3. Inter Server Communication

Persistence

Right now, we have no persistence at all, and we already have the need of login information, so, lets simply use Postgres together with Ecto, the default Datastore tool for Elixir.

First, let's add the dependencies to our commons project's mix.exs, since we want to share this database configuration between all our umbrella apps:

{:ecto, "~> 3.13"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
Enter fullscreen mode Exit fullscreen mode

Now, we can create our Repo module. This is the interface between our application and the database:

defmodule Aesir.Repo do
  use Ecto.Repo,
    otp_app: :commons,
    adapter: Ecto.Adapters.Postgres
end
Enter fullscreen mode Exit fullscreen mode

Simple enough! Now let's configure it. In config/config.exs:

config :commons,
  ecto_repos: [Aesir.Repo]

config :commons, Aesir.Repo,
  database: "aesir_dev",
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  port: 5432,
  show_sensitive_data_on_connection_error: true,
  pool_size: 10
Enter fullscreen mode Exit fullscreen mode

And for our test environment in config/test.exs:

config :commons, Aesir.Repo,
  database: "aesir_test",
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  port: 5432,
  show_sensitive_data_on_connection_error: true,
  pool: Ecto.Adapters.SQL.Sandbox  # Important for test isolation
Enter fullscreen mode Exit fullscreen mode

We also need to create some test helpers and an ExUnit.DataCase, which I won't dive into here, but you can check in the code repository.

The Account Model

Now we have our repo, easy as that! Let's create our account model. Since I'm lazy, I'll just copy the same schema our buddies on rAthena use and translate it to Ecto.

First, let's define the schema with all the fields we need:

defmodule Aesir.Commons.Models.Account do
  use Ecto.Schema
  import Ecto.Changeset

  schema "accounts" do
    field :userid, :string
    field :user_pass, :string
    field :sex, :string, default: "M"
    field :email, :string
    field :group_id, :integer, default: 0        # GM level
    field :state, :integer, default: 0           # Account state (banned, etc)
    field :unban_time, :naive_datetime
    field :expiration_time, :naive_datetime
    field :logincount, :integer, default: 0
    field :lastlogin, :naive_datetime
    field :last_ip, :string
    field :birthdate, :date
    field :character_slots, :integer, default: 9
    field :pincode, :string
    field :pincode_change, :naive_datetime
    field :vip_time, :naive_datetime
    field :old_group, :integer, default: 0
    field :web_auth_token, :string
    field :web_auth_token_enabled, :integer, default: 0

    has_many :characters, Aesir.Commons.Models.Character

    timestamps()
  end
Enter fullscreen mode Exit fullscreen mode

That's a lot of fields! Most are self-explanatory, but some are RO-specific like group_id (GM level) and state (account status).

Now let's add our main changeset with all the validations:

  @doc false
  def changeset(account, attrs) do
    account
    |> cast(attrs, [
      :userid, :user_pass, :sex, :email, :group_id, :state,
      :unban_time, :expiration_time, :logincount, :lastlogin,
      :last_ip, :birthdate, :character_slots, :pincode,
      :pincode_change, :vip_time, :old_group, 
      :web_auth_token, :web_auth_token_enabled
    ])
    |> validate_required([:userid, :user_pass, :email])
    |> validate_format(:email, ~r/@/)
    |> validate_inclusion(:sex, ["M", "F"])
    |> validate_length(:userid, min: 4, max: 23)
    |> validate_length(:user_pass, min: 4, max: 255)
    |> validate_length(:pincode, is: 4)
    |> unique_constraint(:userid)
    |> unique_constraint(:email)
  end
Enter fullscreen mode Exit fullscreen mode

We also need a simpler changeset for login - we don't want to require an email just to validate credentials:

  @doc """
  Changeset for login validation without email requirement
  """
  def login_changeset(account, attrs) do
    account
    |> cast(attrs, [:userid, :user_pass])
    |> validate_required([:userid, :user_pass])
    |> validate_length(:userid, min: 4, max: 23)
    |> validate_length(:user_pass, min: 4, max: 255)
  end
end
Enter fullscreen mode Exit fullscreen mode

We also need to create the migration, for the sake of keeping it short, check it in the git repo.

The Auth Module

Good, now what? Well, now we ditch the old memory account manager and implement a new one. Let's do it in commons, not in account-server. Why? There's some cross-cutting authentication that's shared between the servers, so it's easier to do it like that.

Let's start with the module definition and the main authentication function:

defmodule Aesir.Commons.Auth do
  @moduledoc """
  Authentication context for user login and account management.
  """

  alias Aesir.Commons.Models.Account
  alias Aesir.Repo
Enter fullscreen mode Exit fullscreen mode

The heart of our auth system is the authenticate_user function. Notice the security consideration here:

  @doc """
  Authenticates a user with userid and password.

  Returns {:ok, account} on successful authentication or 
  {:error, reason} on failure.
  """
  def authenticate_user(userid, password) when is_binary(userid) and is_binary(password) do
    case get_account_by_userid(userid) do
      nil ->
        # Important: Always verify even when user doesn't exist
        # This prevents timing attacks
        Bcrypt.no_user_verify()
        {:error, :invalid_credentials}

      account ->
        if verify_password(password, account.user_pass) and account_active?(account) do
          {:ok, update_login_info(account)}
        else
          {:error, :invalid_credentials}
        end
    end
  end
Enter fullscreen mode Exit fullscreen mode

See that Bcrypt.no_user_verify()? That's crucial for security - it ensures that authentication takes the same amount of time whether the user exists or not, preventing timing attacks.

Now let's add our account management functions:

  def get_account_by_userid(userid) do
    Repo.get_by(Account, userid: userid)
  end

  def get_account!(id), do: Repo.get!(Account, id)

  def create_account(attrs \\ %{}) do
    attrs =
      Map.put(attrs, :user_pass, hash_password(attrs[:user_pass] || attrs["user_pass"]))

    %Account{}
    |> Account.changeset(attrs)
    |> Repo.insert()
  end
Enter fullscreen mode Exit fullscreen mode

For password handling, we're using bcrypt, which is the industry standard:

  def hash_password(password) when is_binary(password) do
    Bcrypt.hash_pwd_salt(password)
  end

  def verify_password(password, hash) when is_binary(password) and is_binary(hash) do
    Bcrypt.verify_pass(password, hash)
  end
Enter fullscreen mode Exit fullscreen mode

One of the important features is checking if an account is actually allowed to login:

  def account_active?(%Account{} = account) do
    now = NaiveDateTime.utc_now()

    cond do
      account.state == 5 ->  # Blocked by GM
        false

      account.unban_time && NaiveDateTime.compare(now, account.unban_time) == :lt ->
        false  # Still banned

      account.expiration_time && NaiveDateTime.compare(now, account.expiration_time) == :gt ->
        false  # Account expired

      true ->
        true
    end
  end
Enter fullscreen mode Exit fullscreen mode

And when someone successfully logs in, we update their login stats:

  def update_login_info(%Account{} = account) do
    now = NaiveDateTime.utc_now()

    {:ok, updated_account} =
      account
      |> Account.changeset(%{
        logincount: account.logincount + 1,
        lastlogin: now
      })
      |> Repo.update()

    updated_account
  end
Enter fullscreen mode Exit fullscreen mode

Finally, for debugging, we have a helper to understand account states - these are the standard Ragnarok error codes:

  def get_account_state_description(state) do
    case state do
      0 -> "Normal"
      1 -> "Unregistered ID"
      2 -> "Incorrect Password"
      3 -> "This ID is expired"
      4 -> "Rejected from Server"
      5 -> "Blocked by the GM Team"
      # ... more states
      99 -> "This ID has been totally erased"
      _ -> "Unknown state"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Good, we have the Auth module, now, lets go back to our AccountServer, where we handle the Login Packet

  @impl Aesir.Commons.Network.Connection
  def handle_packet(0x0064, %CaLogin{} = login_packet, session_data) do
    Logger.info("Login attempt for user: #{login_packet.username}")

    case Auth.authenticate_user(login_packet.username, login_packet.password) do
      {:ok, account} ->
        handle_successful_login(account, session_data)

      {:error, reason} ->
        handle_failed_login(reason, session_data)
    end
  end
Enter fullscreen mode Exit fullscreen mode

Tada, we have a login backed by Ecto! So we are at the same state, but a bit better, now lets take the next step, the Characters server.

Char Server

Lets remember a bit about the architecture, we are building 3 applications, Account Server, Char Server and Zone Server, the first, we got working in part 1, its by far the simplest one, the later, which will deal with the whole MMO stuff, the most complex. In-between them we have the CharServer, this one will be responsible for:

  1. Character Information: The overall character data like Job, Level, Experience, Equipment and so on.
  2. Char creation and deletion, blocking and so on.

Important packets

As with everything on Ragnarok protocol, the communication is with packets, the flow works mostly like this.

  1. Once the login server authorizes the login, it returns the list of available character servers (we will go on how this works in next chapter)
  2. The client sends the 0x0065 (CH_ENTER) packet to the Char Server IP, which echoes the login account_id, login_id1 and 2 and the account sex.
  3. The char-server, then simply echoes back the account_id, pretty much a handshake acknowledging the client, then, it sends 3 Packets in sequence:
    • HC_CHARACTER_LIST: This one tells the client how many character slots the user has available.
    • HC_BLOCK_CHARACTER: Which Characters are Blocked
    • HC_SECOND_PASSWD_LOGIN: This one is for 2FA, it instructs the client to request it, or not.
  4. In the case of an issue in any of these processes, the server responds with a HC_REFUSE_ENTER, with the reason of the refusal.
  5. The user can create chars (CH_MAKE_CHAR) , delete (CH_REQ_CHAR_DELETE), select a char to play (CH_SELECT_CHAR) and request the char listing (CH_CHARLIST_REQ).
  6. Once the player clicks in play, the Char Server returns to the client the IP and ports for the Zone Server, in which the character will be placed at.

So, its a pretty straightforward flow, lets start with the database part.

The Character Model

For this, we will need a character model. As with the login, we will shamelessly copy from rAthena schemas :D

First, let's start with the schema definition. Brace yourself - this is a big one:

defmodule Aesir.Commons.Models.Character do
  use Ecto.Schema
  import Ecto.Changeset

  schema "characters" do
    belongs_to :account, Aesir.Commons.Models.Account

    field :char_num, :integer      # Slot number (0-14)
    field :name, :string
    field :class, :integer          # Job class (Novice, Swordsman, etc)

    # Levels and experience
    field :base_level, :integer, default: 1
    field :job_level, :integer, default: 1
    field :base_exp, :integer, default: 0
    field :job_exp, :integer, default: 0
    field :zeny, :integer, default: 0
Enter fullscreen mode Exit fullscreen mode

Next come the character stats - the famous six attributes of Ragnarok:

    # Base stats
    field :str, :integer, default: 1
    field :agi, :integer, default: 1
    field :vit, :integer, default: 1
    field :int, :integer, default: 1
    field :dex, :integer, default: 1
    field :luk, :integer, default: 1

    # Health and SP
    field :max_hp, :integer, default: 40
    field :hp, :integer, default: 40
    field :max_sp, :integer, default: 11
    field :sp, :integer, default: 11

    field :status_point, :integer, default: 0
    field :skill_point, :integer, default: 0
Enter fullscreen mode Exit fullscreen mode

Then we have all the social and appearance fields:

    # Social features
    field :party_id, :integer, default: 0
    field :guild_id, :integer, default: 0
    field :partner_id, :integer, default: 0  # Marriage system
    field :father, :integer, default: 0      # Family system
    field :mother, :integer, default: 0
    field :child, :integer, default: 0

    # Appearance
    field :hair, :integer, default: 0
    field :hair_color, :integer, default: 0
    field :clothes_color, :integer, default: 0

    # Equipment display (for character selection screen)
    field :weapon, :integer, default: 0
    field :shield, :integer, default: 0
    field :head_top, :integer, default: 0
    field :head_mid, :integer, default: 0
    field :head_bottom, :integer, default: 0
    field :robe, :integer, default: 0
Enter fullscreen mode Exit fullscreen mode

And location information - where the character is and where they respawn:

    # Location
    field :last_map, :string, default: "new_1-1"  # Novice training ground
    field :last_x, :integer, default: 53
    field :last_y, :integer, default: 111
    field :save_map, :string, default: "new_1-1"  # Respawn point
    field :save_x, :integer, default: 53
    field :save_y, :integer, default: 111

    # Various status fields
    field :online, :integer, default: 0
    field :delete_date, :naive_datetime
    field :unban_time, :naive_datetime
    field :last_login, :naive_datetime

    timestamps()
  end
Enter fullscreen mode Exit fullscreen mode

Boy, this is a big chungus indeed! Now let's add the validation functions. First, the main validation orchestrator:

  @doc """
  Validates character creation data.
  """
  def validate_creation(attrs) do
    with {:ok, name} <- validate_name(attrs[:name]),
         {:ok, stats} <- validate_stats(attrs),
         {:ok, slot} <- validate_slot(attrs[:slot]) do
      {:ok, %{name: name, stats: stats, slot: slot}}
    else
      {:error, reason} -> {:error, reason}
    end
  end
Enter fullscreen mode Exit fullscreen mode

Character names have strict rules to prevent abuse:

  @doc """
  Validates character name.
  """
  def validate_name(name) when is_binary(name) do
    cond do
      String.length(name) < 4 ->
        {:error, :name_too_short}

      String.length(name) > 23 ->
        {:error, :name_too_long}

      not String.match?(name, ~r/^[a-zA-Z0-9_]+$/) ->
        {:error, :name_invalid_chars}

      String.contains?(String.downcase(name), ["gm", "admin", "test"]) ->
        {:error, :name_forbidden}  # No impersonating GMs!

      true ->
        {:ok, name}
    end
  end

  def validate_name(_), do: {:error, :name_required}
Enter fullscreen mode Exit fullscreen mode

For stats validation, remember what we said about modern clients:

  @doc """
  Validates character stats for creation.
  Modern clients MUST send all stats as 1 - anything else is packet tampering.
  """
  def validate_stats(attrs) do
    required_stats = [:str, :agi, :vit, :int, :dex, :luk]

    stats =
      required_stats
      |> Enum.map(&{&1, (attrs[:stats] && attrs[:stats][&1]) || 1})
      |> Enum.into(%{})

    # If any stat is not 1, it's packet tampering
    if Enum.all?(Map.values(stats), &(&1 == 1)) do
      {:ok, stats}
    else
      {:error, :stats_invalid_total}
    end
  end
Enter fullscreen mode Exit fullscreen mode

And some utility functions for character management:

  @doc """
  Validates character slot number.
  """
  def validate_slot(slot) when is_integer(slot) and slot >= 0 and slot <= 14 do
    {:ok, slot}
  end

  def validate_slot(_), do: {:error, :invalid_slot}

  @doc """
  Checks if character is marked for deletion.
  """
  def marked_for_deletion?(%__MODULE__{delete_date: nil}), do: false
  def marked_for_deletion?(%__MODULE__{delete_date: _}), do: true

  @doc """
  Checks if character is banned.
  """
  def banned?(%__MODULE__{unban_time: nil}), do: false

  def banned?(%__MODULE__{unban_time: unban_time}) do
    NaiveDateTime.compare(unban_time, NaiveDateTime.utc_now()) == :gt
  end
end
Enter fullscreen mode Exit fullscreen mode

The Characters Context Module

Now, with the model in place, we can implement a module to deal with the character creation shizzles. This is where the real business logic lives:

defmodule Aesir.CharServer.Characters do
  @moduledoc """
  Context module for character operations.

  This module handles the business logic for character management including
  creation, deletion, retrieval, and related operations. It uses atomic
  transactions to ensure data consistency and delegates to existing modules
  for validation and persistence.
  """
  require Logger
  import Ecto.Query

  alias Aesir.CharServer.Auth
  alias Aesir.Commons.InterServer.PubSub
  alias Aesir.Commons.Models.Character
  alias Aesir.Repo
Enter fullscreen mode Exit fullscreen mode

Character Creation

Let's start with the most complex operation - creating a new character. We use Ecto.Multi for atomic transactions:

  @doc """
  Creates a new character with atomic transaction handling.

  This function orchestrates the entire character creation workflow:
  1. Validates account permissions
  2. Checks character slot availability
  3. Validates character data
  4. Creates character in database
  5. Broadcasts creation event

  Returns {:ok, character} on success or {:error, reason} on failure.
  """
  def create_character(account_id, char_data) do
    Ecto.Multi.new()
    |> Ecto.Multi.run(:account_validation, fn _repo, _changes ->
      Auth.validate_account_permissions(account_id)
    end)
    |> Ecto.Multi.run(:character_count, fn _repo, _changes ->
      get_character_count(account_id)
    end)
    |> Ecto.Multi.run(:permission_check, fn _repo,
                                            %{account_validation: account, 
                                              character_count: count} ->
      Auth.can_create_character?(account.id, count)
    end)
Enter fullscreen mode Exit fullscreen mode

Notice how we're building up a chain of operations? Each step depends on the previous one succeeding. If any step fails, the entire transaction is rolled back.

Now let's add the validation steps:

    |> Ecto.Multi.run(:character_validation, fn _repo, _changes ->
      Character.validate_creation(char_data)
    end)
    |> Ecto.Multi.run(:name_availability, fn _repo, %{character_validation: validated_data} ->
      check_name_availability(validated_data.name)
    end)
    |> Ecto.Multi.run(:slot_availability, fn _repo, %{character_validation: validated_data} ->
      check_slot_availability(account_id, validated_data.slot)
    end)
Enter fullscreen mode Exit fullscreen mode

Finally, if everything checks out, we create the character and handle the result:

    |> Ecto.Multi.run(:character_creation, fn repo, %{character_validation: validated_data} ->
      create_character_record(repo, account_id, validated_data, char_data)
    end)
    |> Repo.transaction()
    |> handle_creation_result(account_id)
  end
Enter fullscreen mode Exit fullscreen mode

Character Deletion

Modern Ragnarok clients don't delete characters immediately - they mark them for deletion after a delay (usually 24 hours). This prevents accidental deletions:

  @doc """
  Requests character deletion with a timer (modern client behavior).

  Sets a deletion date instead of immediately deleting the character.
  The character will be deleted after the configured delay (default 24 hours).
  """
  def request_character_deletion(char_id, account_id) do
    deletion_delay = Application.get_env(:char_server, :deletion_delay, 86400)


    Ecto.Multi.new()
    |> Ecto.Multi.run(:character_lookup, fn _repo, _changes ->
      get_character(char_id)
    end)
    |> Ecto.Multi.run(:ownership_check, fn _repo, %{character_lookup: character} ->
      if character.account_id == account_id do
        {:ok, character}
      else
        {:error, :not_found}  # Can't delete someone else's character!
      end
    end)
Enter fullscreen mode Exit fullscreen mode

We also need to check if the character can be deleted - they can't be in a guild or party:

    |> Ecto.Multi.run(:deletion_check, fn _repo, %{character_lookup: character} ->
      cond do
        Character.marked_for_deletion?(character) ->
          {:error, :already_deleting}

        character.guild_id && character.guild_id > 0 ->
          {:error, :cannot_delete}  # Must leave guild first

        character.party_id && character.party_id > 0 ->
          {:error, :cannot_delete}  # Must leave party first

        true ->
          {:ok, :can_delete}
      end
    end)
Enter fullscreen mode Exit fullscreen mode

If all checks pass, we set the deletion date:

    |> Ecto.Multi.run(:set_deletion_date, fn repo, %{character_lookup: character} ->
      delete_date =
        NaiveDateTime.utc_now()
        |> NaiveDateTime.add(deletion_delay, :second)
        |> NaiveDateTime.truncate(:second)

      character
      |> Ecto.Changeset.change(delete_date: delete_date)
      |> repo.update()
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{set_deletion_date: character}} ->
        delete_timestamp =
          character.delete_date
          |> DateTime.from_naive!("Etc/UTC")
          |> DateTime.to_unix()

        Logger.info(
          "Character #{character.name} (ID: #{character.id}) marked for deletion"
        )

        {:ok, delete_timestamp}

      {:error, :character_lookup, _, _} ->
        {:error, :not_found}

      {:error, reason, _, _} ->
        {:error, reason}
    end
  end
Enter fullscreen mode Exit fullscreen mode

Other Character Operations

We also need functions for listing, selecting, and managing characters:

  @doc """
  Retrieves characters for an account with session validation.
  """
  def list_characters(account_id, session_data) do
    with {:ok, _session} <- validate_session_for_account(account_id, session_data),
         {:ok, characters} <- get_characters_by_account(account_id) do
      {:ok, characters}
    end
  end

  @doc """
  Selects a character and prepares for zone transfer.
  """
  def select_character(account_id, slot) do
    with {:ok, character} <- get_character_by_slot(account_id, slot),
         :ok <- update_character_location(character),
         :ok <- broadcast_character_selected(account_id, character) do
      {:ok, character}
    end
  end
Enter fullscreen mode Exit fullscreen mode

The broadcast_character_selected is particularly interesting - it uses Phoenix PubSub to notify other servers that a character has been selected. We'll dive deep into this in the next chapter!

Helper Functions

And here are some helper functions for querying and checking availability:

  def get_characters_by_account(account_id) do
    query = from c in Character, 
             where: c.account_id == ^account_id, 
             order_by: c.char_num

    {:ok, Repo.all(query)}
  end

  def get_character_by_slot(account_id, slot) do
    query = from c in Character, 
             where: c.account_id == ^account_id and c.char_num == ^slot

    case Repo.one(query) do
      nil -> {:error, :character_not_found}
      character -> {:ok, character}
    end
  end

  def name_available?(name) do
    # Case-insensitive name check
    query = from c in Character, 
             where: fragment("LOWER(?)", c.name) == ^String.downcase(name)

    case Repo.one(query) do
      nil -> true
      _ -> false
    end
  end

  def slot_available?(account_id, slot) do
    case get_character_by_slot(account_id, slot) do
      {:ok, _character} -> false
      {:error, :character_not_found} -> true
    end
  end
Enter fullscreen mode Exit fullscreen mode

Private Helper Functions

The rest of the module contains private helper functions that support the main operations:

  defp create_character_record(repo, account_id, validated_data, char_data) do
    character_attrs = %{
      account_id: account_id,
      char_num: validated_data.slot,
      name: validated_data.name,
      class: 0,  # Everyone starts as Novice
      str: validated_data.stats.str,
      agi: validated_data.stats.agi,
      vit: validated_data.stats.vit,
      int: validated_data.stats.int,
      dex: validated_data.stats.dex,
      luk: validated_data.stats.luk,
      hair: char_data[:hair] || 1,
      hair_color: char_data[:hair_color] || 1,
      clothes_color: char_data[:clothes_color] || 1
    }

    %Character{}
    |> Character.changeset(character_attrs)
    |> repo.insert()
  end
Enter fullscreen mode Exit fullscreen mode

And we have various validation helpers to ensure characters can be deleted safely:

  defp validate_deletion_eligibility(character) do
    cond do
      Character.banned?(character) ->
        {:error, :character_banned}

      character.guild_id && character.guild_id > 0 ->
        {:error, :character_in_guild}  # Must leave guild first

      character.party_id && character.party_id > 0 ->
        {:error, :character_in_party}  # Must leave party first

      true ->
        {:ok, :eligible}
    end
  end

  defp broadcast_character_selected(account_id, character) do
    PubSub.broadcast_character_selected(account_id, character.id, character.name)
    :ok
  end
end
Enter fullscreen mode Exit fullscreen mode

This module handles the whole character lifecycle, so now we have the data layer, let's build the actual CharServer module that will handle the packets.

The CharServer Module

Now comes the fun part - implementing the actual CharServer that handles all the packet communication with the client. Remember from Part 1 that we built a generic Aesir.Commons.Network.Connection behavior? Well, our CharServer will use the same pattern:

defmodule Aesir.CharServer do
  @moduledoc """
  Connection handler for the Character Server.
  Processes character-related packets and manages character operations.
  """
  use Aesir.Commons.Network.Connection

  require Logger

  alias Aesir.CharServer.Characters
  alias Aesir.CharServer.CharacterSession
  # ... packet aliases
end
Enter fullscreen mode Exit fullscreen mode

When a player connects to the Character Server after logging in, the first thing they send is their authentication credentials:

@impl Aesir.Commons.Network.Connection
def handle_packet(0x0065, parsed_data, session_data) do
  Logger.info("Character list requested for account: #{parsed_data.aid}")

  account_id_ack = %AccountIdAck{account_id: parsed_data.aid}

  with {:ok, updated_session} <-
         CharacterSession.validate_character_session(
           parsed_data.aid,
           parsed_data.login_id1,
           parsed_data.login_id2,
           parsed_data.sex
         ),
       {:ok, characters} <- Characters.list_characters(parsed_data.aid, updated_session) do
Enter fullscreen mode Exit fullscreen mode

First, we validate that this is a legitimate session - the login_id1 and login_id2 are tokens that were generated by the AccountServer. If validation passes, we fetch the character list.

Now here's where the Ragnarok protocol gets interesting - we need to send FIVE different packets in a specific order:

    # 1. Configure character slots - tells client how many slots are available
    slot_config = %HcCharacterList{
      normal_slots: 9,
      premium_slots: 0,
      billing_slots: 0,
      producible_slots: 9,
      valid_slots: 15
    }

    # 2. The actual character data
    char_list = %HcAcceptEnter{characters: characters}
Enter fullscreen mode Exit fullscreen mode

And we're not done yet! The client also expects information about blocked characters and PIN codes:

    # 3. Tell client which characters are blocked (none for now)
    blocked_chars = %HcBlockCharacter{
      blocked_chars: []
    }

    # 4. PIN code configuration (we're disabling it with state: 0)
    pincode = %HcSecondPasswdLogin{
      seed: :rand.uniform(0xFFFF),
      account_id: parsed_data.aid,
      state: 0
    }

    # Send all packets in the exact order the client expects
    {:ok, updated_session,
     [account_id_ack, slot_config, char_list, blocked_chars, pincode]}
Enter fullscreen mode Exit fullscreen mode

If anything goes wrong during validation, we send a refusal packet:

  else
    {:error, reason} ->
      Logger.warning("Session validation failed for account #{parsed_data.aid}: #{reason}")
      response = %HcRefuseEnter{reason: 0}
      {:ok, session_data, [response]}
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice how we're sending multiple packets in response? That's the Ragnarok protocol for you - the client expects this specific sequence to properly display the character selection screen. Miss one packet or send them out of order, and the client will either crash or show corrupted data.

Character Creation

Modern Ragnarok clients use packet 0x0A39 for character creation. Let's break down how we handle it:

def handle_packet(0x0A39, parsed_data, session_data) do
  Logger.info("Character creation requested (modern): #{parsed_data.name}")

  account_id = session_data[:account_id]
Enter fullscreen mode Exit fullscreen mode

First, we extract the account ID from our session. Then we build the character data structure:

  char_data = %{
    name: parsed_data.name,
    slot: parsed_data.slot,
    stats: %{
      str: 1, agi: 1, vit: 1,
      int: 1, dex: 1, luk: 1
    },
    hair: parsed_data.hair_style,
    hair_color: parsed_data.hair_color,
    starting_job: parsed_data.starting_job,
    sex: parsed_data.sex
  }
Enter fullscreen mode Exit fullscreen mode

Notice something important here? All stats MUST be 1. Modern clients don't let players allocate stats during character creation anymore - they always send 1 for each stat. If we receive anything else, it's packet tampering and we should reject it.

Now we attempt to create the character:

  case Characters.create_character(account_id, char_data) do
    {:ok, character} ->
      response = %HcAcceptMakechar{character_data: character}
      {:ok, session_data, [response]}

    {:error, reason} ->
      response = creation_error(reason)
      {:ok, session_data, [response]}
  end
end
Enter fullscreen mode Exit fullscreen mode

The creation_error/1 function maps our internal error atoms to the specific error codes the client understands - things like name already taken (0), invalid characters (1), or slot occupied (3).

Character Selection

When a player double-clicks a character to play, the client sends packet 0x0066. This is where things get interesting - we need to tell the client which Zone Server to connect to:

def handle_packet(0x0066, parsed_data, session_data) do
  account_id = session_data[:account_id]

  with {:ok, character} <- Characters.select_character(account_id, parsed_data.slot),
       {:ok, updated_session} <-
         CharacterSession.update_session_for_character_selection(
           session_data,
           character
         ) do
Enter fullscreen mode Exit fullscreen mode

First, we validate that this character belongs to this account and update our session to track which character is being played.

Then comes the crucial part - telling the client where to go next:

    # TODO: Use ZoneServerService when available
    zone_port = Application.get_env(:zone_server, :port, 5121)

    response = %HcNotifyZonesvr{
      char_id: character.id,
      map_name: character.last_map,  # Which map to load
      ip: {127, 0, 0, 1},            # Zone server IP
      port: zone_port                 # Zone server port
    }

    {:ok, updated_session, [response]}
Enter fullscreen mode Exit fullscreen mode

The client will disconnect from the Character Server and immediately connect to the Zone Server at the specified IP and port. But here's the catch - how will the Zone Server know this is a legitimate connection and not someone trying to hack in?

The Missing Piece: Session Management

We have a functional char server now, so lets login! No no no, there's still a missing piece, if you've been following along carefully, you might have noticed something interesting. In the CharServer's handle_packet/3 for 0x0065, we're calling CharacterSession.validate_character_session/4. But wait - how does the CharServer know that this account has already authenticated with the AccountServer?

The client connects to different servers (Account, then Char, then Zone), and each connection is independent TCP socket. So how do we maintain authentication state across these different servers?

This is where things get really interesting, and it's what we'll dive into in the next chapter: Inter-Server Communication and Session Management.

Here's a little teaser of what's coming:

# How does this work?
CharacterSession.validate_character_session(
  parsed_data.aid,
  parsed_data.login_id1,
  parsed_data.login_id2,
  parsed_data.sex
)
Enter fullscreen mode Exit fullscreen mode

The client sends login_id1 and login_id2 - these are session tokens generated by the AccountServer. But the CharServer needs to verify these are valid. In traditional implementations, you might use:

  1. Shared Database: All servers query the same session table
  2. Redis/Memcached: Centralized session storage
  3. Direct Server Communication: CharServer asks AccountServer directly

But we're using Elixir, and we have something much more elegant: Distributed Erlang with Phoenix PubSub and Mnesia.

In the next part, we'll explore how we:

  • Use Phoenix PubSub for real-time event broadcasting between servers
  • Implement distributed session management with Mnesia
  • Handle server clustering and load balancing
  • Deal with network partitions and split-brain scenarios

Stay tuned for Part 3, where we'll unlock the true power of the BEAM for building distributed game servers!

Git Repo: https://github.com/YgorCastor/aesir

Top comments (1)

Collapse
 
ttyobiwan profile image
Piotr Tobiasz

love the content brother, great to see some interesting elixir patterns