Probuild Ex Part One

cover for article Probuild Ex Part One

In this series we will be writing our own league of legends probuilds.

A league of legend probuilds provide easy access league of legends to Pro players builds accross regions ex: (probuilds.net, probuildstats.com)

It's an interesting app to develop, together we will:

Table of contents

Have a peak πŸ‘€ at the final result

https://probuild.mrdotb.com/

Stack used

  • Elixir language:
    • A dynamic, functional language for building scalable and maintainable applications
  • Phoenix Framework
    • The goto web framework for Elixir language that gives you peace of mind from development to production
  • Phoenix LiveView
    • Enables rich, real-time user experiences with server-rendered HTML
  • tailwindcss
    • A utility-first CSS framework
  • postgreSQL
    • Free and open-source relational database

If you are new to Elixir / Phoenix Framework you will have to install:

App UI

UI Diagram

I used excalidraw to make this diagram.

I took inspiration from probuilds.net and probuildstats.com

Features

  • Display games as row with the game time, the pro player, the matchup and some other stats.
  • Query the games by champion, pro player, roles and regions
  • Toogle a row to get the full game detail.
  • Get new game added to the app in real time.

Data modelling & data sources

Riot api

The riot api is a JSON REST api with many endpoints.

We are interested in two kind of endpoints summoner-v4 and the match-v5.

The summoner represent a league of legends account. Pro players have many accounts in differents regions.

{ "id": "2cNWTjUhUDNQlS-WEB1mIj6bePcdTxz17Gecw4RDQ90H4qA", "accountId": "5H_Q0vPz0WFtt1mzOKicsavLEuYjLSDG-gNsKVBO4FjQBg", "puuid": "8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw", "name": "GodinDatZotak", "profileIconId": 7, "revisionDate": 1662838064000, "summonerLevel": 115 }

The match represent a game of league of legend. It's a lot of data there, we will just take what we need. You can check a match full definition on the riot api documentation getMatch

{ "metadata": ..., "info": { "gameDuration": 1052, "gameId": 6060276174, "participants": [ { "kills": 1, "assists": 1, "deaths": 1 ... }, ... ] } }

Pro players list

There is no open/free api to my knowledge of league professional player. The more accessible data about pro player is an endpoint on U.GG. I asked them before on their discord and it's not against their TOS to use the endpoint.

https://stats2.u.gg/pro/pro-list.json

One summoner from UGG json

[ { "current_ign": "Hide on bush", "current_team": "T1", "league": "LCK", "main_role": "mid", "normalized_name": "faker", "official_name": "Faker", "region_id": "kr" }, ... ]

DB diagram

I used datagrip to make this DB diagram.

  • team have many pro players (ex: T1)
  • pro have many summoners (multiples league of legends accounts ex: "Faker")
  • summoner have many participants (ex: "Hide on bush")
  • game have 10 participants (league of legends is a 5vs5 players game)
  • participant have one opponent participant (participant have an opponent in the enemy team who have the same position ex: Middle, Top ...)

Show me the code!

The final repo is on github in case you get lost or you want to skip some step you can checkin a specific part there.

Bootstrap phoenix & HTTP client & schemas and migrations

Generate a new Phoenix project and install dependencies - commit

If you did not install elixir, phoenix and postgres yet check the links in stack used

Install the Phoenix project generator phx.new (if you don’t already have it installed) by running:

mix archive.install hex phx_new 1.6.12

Generate a new Phoenix project with (we don't need the mailer and the internationalization)

mix phx.new --no-gettext --no-mailer probuild_ex

Once the project is created, open up mix.exs. We will add tesla my goto HTTP client and hackney to use as an adapter. In the deps section add.

defp deps do [ ... {:tesla, "~> 1.4"}, {:hackney, "~> 1.13"} ] end

Then open up config/config.exs and add this line

config :tesla, :adapter, Tesla.Adapter.Hackney

Fetch pro player from UGG endpoint - commit

Create a new file in lib/probuild_ex/ugg.ex

defmodule ProbuildEx.UGG do @moduledoc false @url "https://stats2.u.gg/pro/pro-list.json" # If they change the endpoint in the future you can use the url below instead # it's a snapshot of the pro-list.json endpoint the 13 August 2022 # @url "https://gist.githubusercontent.com/mrdotb/0d11ce00445de1f2573b8e74a9fcc5f7/raw/a0ff759bb1b794611f8c7a60b2a68bdc7d5eba80/pro-list.json" def pro_list do %{body: body} = Tesla.get!(@url) Jason.decode!(body) end end

The pro_list/0 function above in the module will do a GET request to the UGG endpoint then pass the body to Jason to convert this JSON result to an elixir representation.

Let's test our module from IEx (Elixir's interactive shell)

iex -S mix phx.server iex> ProbuildEx.UGG.pro_list() [ %{ "current_ign" => "μ„œμͺ½μ—μ„œ 졜고", "current_team" => "Golden Guardians", "league" => "LCS", "main_role" => "top", "normalized_name" => "licorice", "official_name" => "Licorice", "region_id" => "kr" }, %{ "current_ign" => "TitaN", "current_team" => "RED Kalunga", "league" => "CBLOL", "main_role" => "adc", "normalized_name" => "titan", "official_name" => "TitaN", "region_id" => "br1" }, ... ]

Looks nice we got our list of pro player in elixir.

Fetch riot data from their api - commit

You will need a league of league of legends account to get a riot token from their dashboard.

We will put the token in a local dev config. It's good practice to ignore tokens from git.

Edit file .gitignore

# ignore local config files /config/*.local.exs

Edit file config/dev.exs add to the bottom

if File.exists?(Path.expand("dev.local.exs", __DIR__)) do import_config "dev.local.exs" end

Create file config/dev.local.exs

import Config # put your token below config :probuild_ex, ProbuildEx.RiotApi, token: "RGAPI-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx"

Now that we got the token in our config we can create the RiotApi module

Create a new file in lib/probuild_ex/riot_api.ex

defmodule ProbuildEx.RiotApi do @moduledoc """ A thin wrapper around the rest riot api for the endpoint we are interested in. """ require Logger @ranked_solo_game 420 @regions_routing_map %{ "americas" => ["na1", "br1", "la1", "la2"], "asia" => ["kr", "jp1"], "europe" => ["eun1", "euw1", "tr1", "ru"], "sea" => ["oc1"] } @regions Map.keys(@regions_routing_map) @platform_ids_routing_map %{ "br1" => "americas", "jp1" => "asia", "kr" => "asia", "la1" => "americas", "la2" => "americas", "na1" => "americas", "oc1" => "sea", "ru" => "europe", "tr1" => "europe", "eun1" => "europe", "euw1" => "europe" } @platform_ids Map.keys(@platform_ids_routing_map) # Get token from config. defp token do Application.get_env(:probuild_ex, __MODULE__)[:token] end @doc """ Create a tesla client. """ def new(region, option \\ nil) do middlewares = [ # this will make the request retry automatically when we hit the rate limit # and get a 429 status or the riot api return a 500 status {Tesla.Middleware.Retry, [ delay: 10_000, max_retries: 20, max_delay: 60_000, should_retry: fn {:ok, %{status: status}} when status in [429, 503] -> true {:ok, _} -> false {:error, _} -> true end ]}, # pass the riot token in header {Tesla.Middleware.Headers, [{"X-Riot-Token", token()}]}, # set the BaseUrl depending what region endpoint we want to call {Tesla.Middleware.BaseUrl, url(region, option)}, # parse the JSON response automatically Tesla.Middleware.JSON, # Logger Tesla.Middleware.Logger ] Tesla.client(middlewares) end # Depending on the endpoint we need to put a region or a platform_id # in some case we want the region who match the platform_id defp url(region_or_platform_id, option) defp url(region, nil) when region in @regions do "https://#{region}.api.riotgames.com" end defp url(platform_id, nil) when platform_id in @platform_ids do "https://#{platform_id}.api.riotgames.com" end defp url(platform_id, :convert_platform_to_region_id) when platform_id in @platform_ids do region = Map.get(@platform_ids_routing_map, platform_id) url(region, nil) end @doc """ Given a tesla client a puuid and optionnaly a start return a list of ranked_solo_game match ids. ## Example iex> RiotApi.list_matches(client, "8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw") ["EUW1_5794787018", "EUW1_5786706582", "EUW1_5777719214", "EUW1_5723851410", "EUW1_5630385359", "EUW1_5630305794", ...] """ def list_matches(client, puuid, start \\ 0) do path = "/lol/match/v5/matches/by-puuid/#{puuid}/ids?" query = URI.encode_query(start: start, count: 100, queue: @ranked_solo_game) %{body: match_ids, status: 200} = Tesla.get!(client, path <> query) match_ids end @doc """ Given a tesla client and a match_id return a match_data. ## Example iex> RiotApi.fetch_match(client, "EUW1_5794787018") {:ok, %{ "info" => ..., "metadata" => ... } } """ def fetch_match(client, match_id) do path = "/lol/match/v5/matches/#{match_id}" case Tesla.get!(client, path) do %{status: 200, body: match_data} -> {:ok, match_data} %{status: 404} -> {:error, :not_found} other -> Logger.error(other) {:error, :unknow_error} end end @doc """ Given a tesla client and a summoner_name get summoner_data ## Example iex> RiotApi.fetch_summoner_by_name(client, "godindatzotak") {:ok, %{ "accountId" => "5H_Q0vPz0WFtt1mzOKicsavLEuYjLSDG-gNsKVBO4FjQBg", "id" => "2cNWTjUhUDNQlS-WEB1mIj6bePcdTxz17Gecw4RDQ90H4qA", "name" => "GodinDatZotak", "profileIconId" => 7, "puuid" => "8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw", "revisionDate" => 1660161403000, "summonerLevel" => 112 }} """ def fetch_summoner_by_name(client, summoner_name) do path = "/lol/summoner/v4/summoners/by-name/#{summoner_name}" case Tesla.get!(client, path) do %{status: 200, body: summoner_data} -> {:ok, summoner_data} %{status: 404} -> {:error, :not_found} other -> Logger.error(other) {:error, :unknow_error} end end @doc """ Given a tesla client and a puuid get summoner_data Keep in mind that puuid depends on your Token ## Example iex> RiotApi.fetch_summoner_by_puuid(client, "8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw") {:ok, %{ "accountId" => "5H_Q0vPz0WFtt1mzOKicsavLEuYjLSDG-gNsKVBO4FjQBg", "id" => "2cNWTjUhUDNQlS-WEB1mIj6bePcdTxz17Gecw4RDQ90H4qA", "name" => "GodinDatZotak", "profileIconId" => 7, "puuid" => "8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw", "revisionDate" => 1660161403000, "summonerLevel" => 112 }} """ def fetch_summoner_by_puuid(client, puuid) do path = "/lol/summoner/v4/summoners/by-puuid/#{puuid}" case Tesla.get!(client, path) do %{status: 200, body: summoner_data} -> {:ok, summoner_data} %{status: 404} -> {:error, :not_found} other -> Logger.error(other) {:error, :unknow_error} end end end

The platform_ids and regions Map on the top are used to construct the url depending on the ressource match, summoner we need to use the region or platform_id in the request url. Ex:

`https://europe.api.riotgames.com/lol/match/v5/matches/by-puuid/8tjefad_ZLY2X8UbmwYlR1PBtaRgJBxcOcvFZ8tMy6f4bw56fMaIvLoqA87DK3yzqihZs7L-VQCdBw/ids?start=0&count=20` `https://eun1.api.riotgames.com/lol/summoner/v4/summoners/by-account/CZV2GRJ_26fBaV87oUY8LYWFVVlXbUjkG5bKHFWXzfZex20`

The HTTP client tesla come with many usefull middlewares. A tesla middleware is an extra step before / after the request to modify it. The api is rate limited and return 429 and sometimes 503 the Retry middleware will retry automatically, the others middleware are self explanatory.

We implemented the four calls needed to the riot api:

  • GET /lol/match/v5/matches/by-puuid/:puuid/ids
  • GET /lol/match/v5/matches/:match_id
  • GET /lol/summoner/v4/summoners/by-name/:name
  • GET /lol/summoner/v4/summoners/by-puuid/:puuid

Let's test our module from IEx

iex -S mix phx.server iex> client_euw1 = ProbuildEx.RiotApi.new("euw1") %Tesla.Client{ ... } iex> {:ok, summoner} = ProbuildEx.RiotApi.fetch_summoner_by_name(client_euw1, "godindatzotak") {:ok, %{ "accountId" => "Yswxna2EdGrxiY-278KVBej4a1RdE6SeiHa8btUKZpefUw", "id" => "dw7jlSdJXZaoQtnITLEQPp-cSIRxQt2NUSQ__MZyvlmCvQA", "name" => "GodinDatZotak", "profileIconId" => 7, "puuid" => "RHEsqWf2CHJldRo39tu0RaejKyI6ZXQt1JfkhavIPZ1m-EBzW9JLNKRGKYmwNJTT1mdJgBi7FErztg", "revisionDate" => 1664038307000, "summonerLevel" => 118 }} iex> client_europe = ProbuildEx.RiotApi.new("europe") %Tesla.Client{ ... } iex> ProbuildEx.RiotApi.list_matches(client_europe, summoner["puuid"]) ["EUW1_6077658796", "EUW1_6007773996", ...]

Looks nice we get the summoner for a name then we list his matches.

Migrations & schemas - commit

We will create our 5 entities following the previous diagram

We will use phx.gen.schema to bootstrap our migration and ecto schema quickly.

mix phx.gen.schema Games.Team teams name:text:unique
mix phx.gen.schema Games.Pro pros name:text:unique team_id:references:teams
mix phx.gen.schema Games.Summoner summoners \ name:text puuid:text \ platform_id:enum:br1:eun1:euw1:jp1:kr:la1:la2:na1:oc1:ru:tr1 \ pro_id:references:pros
mix phx.gen.schema Games.Game games \ creation:utc_datetime duration:integer \ platform_id:enum:br1:eun1:euw1:jp1:kr:la1:la2:na1:oc1:ru:tr1 \ riot_id:text:unique version:text winner:integer
mix phx.gen.schema Games.Participant participants \ assists:integer champion_id:integer deaths:integer gold_earned:integer \ items:array:integer kills:integer summoners:array:integer \ team_position:enum:UTILITY:TOP:JUNGLE:MIDDLE:BOTTOM \ team_id:integer win:boolean \ game_id:references:games summoner_id:references:summoners \ opponent_participant_id:references:participants

We will need to tweak a bit the generated schemas and migrations. I like to disallow NULL value in my database unless it's needed which is not the default with ecto. We will also set the on_delete: to :delete_all for delete to Cascade properly.

Replace XXXXXXXX with the timestamp

Edit migrations/XXXXXXXXXX_create_teams.exs

add :name, :string, null: false

We set :name non NULL.

Edit migration/XXXXXXXXX_create_pros.exs

add :name, :text, null: false add :team_id, references(:teams, on_delete: :delete_all), null: false

We set attributes non NULL and add a on_delete: :delete_all to properly Cascade the delete.

Edit lib/probuild_ex/games/pro.ex

defmodule ProbuildEx.Games.Pro do use Ecto.Schema import Ecto.Changeset alias ProbuildEx.Games.Team schema "pros" do field :name, :string belongs_to :team, Team timestamps() end @doc false def changeset(pro, attrs) do pro |> cast(attrs, [:name, :team_id]) |> validate_required([:name, :team_id]) |> unique_constraint(:name) |> foreign_key_constraint(:team_id) end end

We add belongs_to to Team and add some contraint check in the changeset.

Edit migration/XXXXXXXXX_create_summoners.exs

defmodule ProbuildEx.Repo.Migrations.CreateSummoners do use Ecto.Migration def change do create table(:summoners) do add :name, :text, null: false add :puuid, :text, null: false add :platform_id, :string, null: false # Note the pro_id can be null add :pro_id, references(:pros, on_delete: :delete_all), null: true timestamps() end create unique_index(:summoners, [:puuid, :platform_id]) create index(:summoners, [:pro_id]) end end

We set attributes non NULL and the :on_delete like before. We also create a unique index using the puuid and platform_id to prevent duplicate. (I encounter a case where two summoners got the same puuid in different region)

Edit lib/probuild_ex/games/summoner.ex

defmodule ProbuildEx.Games.Summoner do use Ecto.Schema import Ecto.Changeset alias ProbuildEx.Games.Pro schema "summoners" do field :name, :string field :platform_id, Ecto.Enum, values: [:br1, :eun1, :euw1, :jp1, :kr, :la1, :la2, :na1, :oc1, :ru, :tr1] field :puuid, :string belongs_to :pro, Pro timestamps() end @doc false def changeset(summoner, attrs) do summoner |> cast(attrs, [:puuid, :platform_id, :pro_id, :name]) |> validate_required([:puuid, :platform_id, :name]) |> unique_constraint([:puuid, :platform_id], name: "summoners_puuid_platform_id_index") |> foreign_key_constraint(:pro_id) end end

Same as before we add a belongs_to and add constraint check in the changeset.

Edit migration/XXXXXXXXX_create_games.exs

add :creation, :utc_datetime, null: false add :duration, :integer, null: false add :platform_id, :text, null: false add :riot_id, :string, null: false add :version, :text, null: false add :winner, :smallint, null: false

Same as before we disallow NULL

Edit lib/probuild_ex/games/game.ex

defmodule ProbuildEx.Games.Game do use Ecto.Schema import Ecto.Changeset alias ProbuildEx.Games.Participant schema "games" do field :creation, :utc_datetime field :duration, :integer field :platform_id, Ecto.Enum, values: [:br1, :eun1, :euw1, :jp1, :kr, :la1, :la2, :na1, :oc1, :ru, :tr1] field :riot_id, :string field :version, :string field :winner, :integer has_many :participants, Participant timestamps() end @doc false def changeset(game, attrs) do game |> cast(attrs, [:creation, :duration, :platform_id, :riot_id, :version, :winner]) |> validate_required([:creation, :duration, :platform_id, :riot_id, :version, :winner]) |> unique_constraint(:riot_id) end end

We add has_many Participant.

Edit migration/XXXXXXXXX_create_participants.exs

defmodule ProbuildEx.Repo.Migrations.CreateParticipants do use Ecto.Migration def change do create table(:participants) do add :assists, :integer, null: false add :champion_id, :integer, null: false add :deaths, :integer, null: false add :gold_earned, :integer, null: false add :items, {:array, :integer}, null: false add :kills, :integer, null: false add :summoners, {:array, :integer}, null: false add :team_position, :string, null: false add :team_id, :integer, null: false add :win, :boolean, null: false add :game_id, references(:games, on_delete: :delete_all), null: false add :summoner_id, references(:summoners, on_delete: :delete_all), null: false # Note the opponent_participant can be null add :opponent_participant_id, references(:participants, on_delete: :delete_all), null: true timestamps() end create index(:participants, [:game_id]) create index(:participants, [:summoner_id]) create index(:participants, [:opponent_participant_id]) end end

We set attributes non NULL and the :on_delete like before.

Edit lib/probuild_ex/games/participant.ex

defmodule ProbuildEx.Games.Participant do use Ecto.Schema import Ecto.Changeset alias ProbuildEx.Games.{ Game, Participant, Summoner } schema "participants" do field :assists, :integer field :champion_id, :integer field :deaths, :integer field :gold_earned, :integer field :items, {:array, :integer} field :kills, :integer field :summoners, {:array, :integer} field :team_id, :integer field :team_position, Ecto.Enum, values: [:UTILITY, :TOP, :JUNGLE, :MIDDLE, :BOTTOM] field :win, :boolean, default: false belongs_to :game, Game belongs_to :summoner, Summoner belongs_to :opponent_participant, Participant timestamps() end @doc false def changeset(participant, attrs) do participant |> cast(attrs, [ :assists, :champion_id, :deaths, :gold_earned, :items, :kills, :summoners, :team_position, :team_id, :win, :game_id, :summoner_id, :opponent_participant_id ]) |> validate_required([ :assists, :champion_id, :deaths, :gold_earned, :items, :kills, :summoners, :team_position, :team_id, :win, :game_id, :summoner_id ]) |> foreign_key_constraint(:game_id) |> foreign_key_constraint(:summoner_id) |> foreign_key_constraint(:opponent_participant_id) end end

We add belongs_to and foreign_key_constraint.

Running migrations

All our migrations are ready let's run them.

mix ecto.migrate

Closing thoughts

Well done and thanks for sticking with me to the end! We built the foundation for our probuild application, created our HTTP clients to UGG and the riot api and modelling our database.

In the next part we will focus on collecting the Pros and Games data with GenServer processes and insert those in our database.

Be sure to sign up to the newsletter so that you won't miss the next Part. Feel free to leave comments or feedback. I also appreciate if you can star ⭐ the companion code repo.

See you soon !

Stay up to date

Sign up for the mailing list and get notified via email when new blog posts come out.