Skip to main content

Character Creation

We have a basic server, and basic user/session management. Now players need the ability to create characters.

The Character Model

We can use an Ecto generator to build us a database model and migration for the Character entity. From the web app apps/elixir_mud_web run:

 mix phx.gen.context Characters Character characters\
 name:string\
 player_id:references:players\
 race:enum:human:elf:halfling\
 level:integer

This command generates us a table migration, a new Characters context, and a Character model. Lets modify the migration slightly. We don't want characters to be created without level, name, race, or player. We will prevent null values on name, race, and player_id. For level we can set a default of 1.

defmodule ElixirMud.Repo.Migrations.CreateCharacters do
  use Ecto.Migration

  def change do
    create table(:characters) do
      add :name, :string, null: false
      add :race, :string, null: false
      add :level, :integer, null: false, default: 1
      add :player_id, references(:players, on_delete: :delete_all), null: false

      timestamps()
    end

    create index(:characters, [:player_id])
  end
end

apps/elixir_mud/priv/repo/xxxx_create_characters.exs

Now we can modify the database model. We will add player_id to cast so it can be passed in when creating a character and to validate_required so it must be present. We can also remove level from validate_required since it can be omitted and will default to 1.

defmodule ElixirMud.Characters.Character do
  use Ecto.Schema
  import Ecto.Changeset

  schema "characters" do
    field :name, :string
    field :level, :integer
    field :race, Ecto.Enum, values: [:human, :elf, :halfling]
    field :player_id, :id

    timestamps()
  end

  @doc false
  def changeset(character, attrs) do
    character
    |> cast(attrs, [:name, :race, :level, :player_id])
    |> validate_required([:name, :race,  :player_id])
  end
end

apps/elixir_mud/lib/elixir_mud/characters/character.ex

Form the root of the project migrate the database with:

mix ecto.migrate

Characters Context

The generator gave us some basic management function in characters.ex, but we will need more. Instead of creating all our query logic in the context file we can create reusable query fragments that can be composed into full queries. We will put these in the character.ex model.

First add a few imports and aliases to the top of the module. Aliasing the current module simply gives us the ability to reference it directly instead of using __MODULE__, which looks a bit cleaner.

use Ecto.Schema
import Ecto.Changeset
import Ecto.Query, warn: false
alias ElixirMud.Accounts.Player
alias ElixirMud.Characters.Character

Then, we will add four new query fragment functions:

  • query_for_player/2 will allow us to query characters for a given player.
  • query_for_id/2 will allow us to query characters by a character id.
  • order_name/1 will allow us to order the characters by their name.
  • query_with_player/1 will allow us to preload the player details when looking up a character.
def query_for_player(query \\ Character, %Player{} = player) do 
  from c in query,
    where: c.player_id == ^player.id
end

def query_for_id(query \\ Character, id) do
  from c in query,
    where: c.id == ^id
end

def order_name(query \\ Character) do
  from c in query,
    order_by: [asc: c.name]
end

def query_with_player(query \\ Character) do
  from c in query,
    join: p in assoc(c, :player),
    preload: [player: p]
end

apps/elixir_mud/lib/elixir_mud/characters/character.ex

Each of these functions take a query as their first parameter and the return a query. This allows them to be chained together to easily make complex queries.

We can modify the generated context functions for characters to use our new composable queries. list_characters/0 should order by name:

def list_characters do
  Character.order_name()
  |> Repo.all()
end

apps/elixir_mud/lib/elixir_mud/characters.ex

We should also be able to list characters by player:

def list_characters_for_player(%Player{} = player) do
  Character.query_for_player(player)
  |> Repo.all()
end

apps/elixir_mud/lib/elixir_mud/characters.ex

We can add a non dangerous get_character function using composable queries:

def get_character(id) do
  Character.query_for_id(id)
  |> Repo.one()
end

apps/elixir_mud/lib/elixir_mud/characters.ex

Lets make a delete function that is restricted by player:

Add a alias for Player, near the top of the module:

  alias ElixirMud.Accounts.Player

apps/elixir_mud/lib/elixir_mud/characters.ex

Add a new delete_character_for_player/2 function near delete_character/1:

  def delete_character_for_player(
        %Player{} = player,
        %Character{} = character
      ) do
    Character.query_for_id(character.id)
    |> Character.query_for_player(player)
    |> Repo.delete_all()
  end

apps/elixir_mud/lib/elixir_mud/characters.ex

Character Context Tests

We need to add unit tests for our new context functions. There is already a test file that was generated that contains tests for the generated functions. We will add a few new test cases.

test "list_characters_ or_player/1 returns characters for a specific player" do
  character_fixture()
  player = ElixirMud.AccountsFixtures.player_fixture()
  character = character_fixture(player_id: player.id)
  assert Characters.list_characters_for_player(player) == [character]
end

test "delete_character_for_player/2" do
  player = ElixirMud.AccountsFixtures.player_fixture()
  character = character_fixture(player_id: player.id)

  Characters.delete_character_for_player(player, character)
  assert Characters.list_characters() == []
end

apps/elixir_mud/test/elixir_mud/characters_test.exs

We also need to modify ElixirMud.CharactersFixtures to automatically fill out player_id if its not specified:

defmodule ElixirMud.CharactersFixtures do
  import ElixirMud.AccountsFixtures

  @moduledoc """
  This module defines test helpers for creating
  entities via the `ElixirMud.Characters` context.
  """

  @doc """
  Generate a character.
  """
  def character_fixture(attrs \\ %{}) do
    {:ok, character} =
      attrs
      |> Enum.into(%{
        level: 42,
        name: "some name",
        race: :human,
        player_id: player_fixture().id
      })
      |> ElixirMud.Characters.create_character()

    character
  end
end

apps/elixir_mud/test/support/fixtures/characters_fixture.ex

And we can run mix test and see all greens!

Character Creation UI

Now that we have some plumbing, we can setup a UI for users to create a character. We will implement this using a Phoenix LiveView. Create a new file at apps/elixir_mud_web/lib/elixir_mud_web/live/characters/create_character_live.ex.

Normally, it is a good idea to create a separate form component. This can then be used for creation and modification. There isn't going to be a direct need to edit characters once they are created, we will create the form handling directly in the LivePage.

A LivePage normally has the following function:

  • mount/3 - Called when the page is initially mounted.
  • handle_params/3 - Called when the page loads or the params change.
  • render/1 - Called to render the page's content.

Here is the b CreateCharacterLive.ex module:

defmodule ElixirMudWeb.Characters.CreateCharacterLive do
  use ElixirMudWeb, :live_view
  alias ElixirMud.Characters

  def mount(_params, _session, socket) do
    character_form =
      Characters.change_character(%Characters.Character{}, %{})
      |> to_form()

    {:ok, assign(socket, form: character_form)}
  end

  def render(assigns) do
    ~H"""
    <div class="border rounded-lg py-4 px-8">
      <.header>Create a New Character</.header>
      <p class="text-sm text-zinc-500">
        Name your character and login to get started.
      </p>
      <.simple_form for={@form} phx-submit="create-character" phx-change="validate-character">
        <.input field={@form[:name]} />
        <:actions>
          <.button>Create</.button>
        </:actions>
      </.simple_form>
    </div>
    """
  end
end

apps/elixir_mud_web/lib/elixir_mud_web/live/characters/create_character.ex

Lets break down what's happening:

This LiveView Page features a simple form allowing the player to input and submit a character name. It has two functions implemented.

The first, mount/3, initializes the character form data and assigns it to the LiveView (socket). The Characters.change_character/2 function was generated for us by the Phoenix generator. This function accepts a character struct and a map of updates, and returns an Ecto Changeset.

The second function, render/1 renders our form with some minimal styling from Tailwind. The simple_form component, created by phx.new, is located in apps/elixir_mud_web/lib/elixir_mud_web/components/core_components.ex. The phx-submit and phx-change attributes specify events that trigger on form submit and change respectively. We will handle those functions with handle_event/3 further down.

Routing

In order to be able to visit the page, routing must be implemented for the Live View. This route will point to /play. Create a new live_session block inside router.ex. We do not want to use the other blocks, as the only other authenticated block is the player settings and we are using the blank layout for it.

scope "/", ElixirMudWeb do
  pipe_through [:browser, :require_authenticated_player]

  live_session :authenticated_player,
    on_mount: [{ElixirMudWeb.PlayerAuth, :ensure_authenticated}] do
    live "/play", Characters.CreateCharacterLive, :create
  end
end

apps/elixir_mud_web/lib/elixir_mud_web/router.ex

This introduces a new scope for the page root along with a new live_session, arbitrarily named authenticated_player. Next, we define pipelines the scope will use. The :browser pipeline is defined at the top of the module, establishing handling we would expect for browser based sessions. We also are using :require_authenticated_player, which points to a function located in the auth.ex file generated by the phx.gen.auth mix task. It will redirect a visitor to the login page when they are not authenticated.

Within the scope block is the live_session block, with its passed name, and middleware invoked during the initial session creation. This is the module and name for the middleware. In this case, its referring to the function on_mount(:ensure_authenticated, _, _, _); which is also inside auth.ex.

The last piece of the puzzle is the live route, within the live_session block. This designates the /play route for LiveView, with a live action of :create. The live action can be used to conditionally render based on the URL.

Handling The Form Submission

A form doesn't do much if its not functional.

Form Validation

Before we handle the form submission, lets write a test. Create a new file at apps/elixir_mud_web/test/elixir_mud_web/live/characters/create_character_live_test.exs.

We will set some constraints on the character name length:

defmodule ElixirMudWeb.Characters.CreateCharacterLiveTest do
  use ElixirMudWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  describe "Character creation" do
    setup :register_and_log_in_player

    test "validates length of name", %{conn: conn} do
      {:ok, lv, _html} =
        conn
        |> live(~p"/play")

      html =
        form(lv, "#create-character-form", %{
          "character" => %{"name" => "A"}
        })
        |> render_change()

      assert html =~ "at least 2 character(s)"
    end
end

apps/elixir_mud_web/test/elixir_mud_web/live/characters/create_character_live_test.exs

Of note, we are using a setup clause setup :register_and_log_in_player. This sets up a new user and logs it into the current session, before each test is run. This is provided in conn_case.ex and was auto generated by the auth generator.

This will test if entering simply A will show us an error message that the name must be at least 2 characters. If we run this test now it will fail. First thing is to set that validation on the model itself. Ecto provides helpful change set validation helper functions for all sorts of common validations. Change the changeset/2 function inside character.ex, to add the validate_length/2 validation helper.

@doc false
def changeset(character, attrs) do
  character
  |> cast(attrs, [:name, :race, :level, :player_id])
  |> validate_required([:name, :race, :player_id])
  |> validate_length(:name, min: 2, max: 10)
end

apps/elixir_mud_web/lib/elixir_mud_web/live/characters/create_character_live.ex

Now, we can add our validation event to create_character_live.ex. Place this after the render/1 function.

def handle_event("validate-character", %{"character" => params}, socket) do
  character_form =
    Characters.change_character(%Characters.Character{}, params)
    |> Map.put(:action, :validate)
    |> to_form()

  {:noreply, assign(socket, form: character_form)}
end

apps/elixir_mud_web/lib/elixir_mud_web/live/characters/create_character_live.ex

Here, we are calling change_character/2 from the Characters module, which intern calls our changeset/2 function in Character. This is where that validation is happening. We convert the value to form data with Phoenix's helper function to_form/1 and reassign it.

This event is called any time the values are changed in the form fields. This happens automatically for us because of the phx-change attribute that is on the simple_form view component from the render/1 function.

Now if we run our tests the form validation test will pass. However, a few tests from the Characters module are failing because of that validation. We can update those.

First the update test uses a name that's too long, we can change "some updated name" to "newname".

test "update_character/2 with valid data updates the character" do
  character = character_fixture()
  update_attrs = %{name: "newname", level: 43, race: :elf}

  assert {:ok, %Character{} = character} =
           Characters.update_character(character, update_attrs)

  assert character.name == "newname"
  assert character.level == 43
  assert character.race == :elf
end

apps/elixir_mud/test/elixir_mud/character_test.exs

Second, the create character test is failing because it is missing a player_id. We can solve this by using the player fixture.

test "create_character/1 with valid data creates a character" do
  valid_attrs = %{
    name: "some name", 
    level: 42, 
    race: :human, 
    player_id: ElixirMud.AccountsFixtures.player_fixture().id
  }

  assert {:ok, %Character{} = character} = Characters.create_character(valid_attrs)
  assert character.name == "some name"
  assert character.level == 42
  assert character.race == :human
end

apps/elixir_mud/test/elixir_mud/character_test.exs

Now all test should be passing.

Form Submission

The form is already wired for the submission event thanks to the phx-submit attribute on the simple_form component. We just need to handle the event like we did for the validation event. But first, a test, add this after the validation test.

test "submits creates character", %{conn: conn} do
  {:ok, lv, _html} =
    conn
    |> live(~p"/play")

  {:ok, _lv, html} =
    form(lv, "#create-character-form", %{
      "character" => %{"name" => "Alice"}
    })
    |> render_submit()
    |> follow_redirect(conn, ~p"/play")

  assert html =~ "created successfully"
end

apps/elixir_mud_web/test/elixir_mud_web/live/characters/create_character_live_test.exs

This will simply fill out the name in the form and submit it. We are verifying that the form submits successfully, redirects to /play, and matches the flash message. Now we add the handle_event/3 for processing the form submission.

def handle_event("create-character", %{"character" => params}, socket) do
  case Characters.create_character(params) do
    {:ok, character} ->
      {:noreply,
       socket
       |> put_flash(:info, "Character created successfully.")
       |> redirect(to: ~p"/play/#{character.id}")}
  
    {:error, changeset} ->
      character_form = to_form(changeset)
      {:noreply, assign(socket, form: character_form)}
  end
end

apps/elixir_mud_web/lib/elixir_mud_web/live/characters/create_character_live.ex

We should now have all green tests. Next we will refactor things a bit to create a play screen, independent of character creation.

Updated on Jul 1, 2025