DEV Community 👩‍💻👨‍💻

Cover image for How we built open source Kahoot! alternative with Elixir and GraphQL
Andrew Lee for Playhouse

Posted on • Updated on

How we built open source Kahoot! alternative with Elixir and GraphQL

Our open source backend is an Elixir Phoenix backend that is both a GraphQL server and a socket server. CRUD operations to build packs (group of questions) are done through GraphQL and live games are powered through sockets.


We did not expect GraphQL tooling in Elixir to be so good. Absinthe is a comprehensive GraphQL toolkit for Elixir that is incredible to use.


One of our favorite features of Absinthe is the Relay option. Absinthe Relay docs articulates the three reasons why we decided to use it:

Absinthe.Relay supports three fundamental pieces of the Relay puzzle: nodes, which are normal GraphQL objects with a unique global ID scheme; mutations, which in Relay Classic conform to a certain input and output structure; and connections, which provide enhanced functionality around many-to-one lists (most notably pagination).


Elixir is a joy to write in! For example, writing authorization logic was a joy in Elixir was a joy.

def check(:pack_create, %User{}, %Pack{}), do: {:ok}

def check(:pack_create, nil, %Pack{}), do: {:unauthorized}
Enter fullscreen mode Exit fullscreen mode

We decided not to couple authorization with GraphQL. Instead we perform authorization at the context level right before we access the database.

defmodule Database.Catalog do
  use Database.Context

  # ...

  def scene_answer_create(%User{} = user, %Scene{} = scene, answer, is_correct) do
    with {:ok} <- Authorization.check(:scene_answer_create, user, scene) do
      |> SceneAnswer.changeset(%{content: answer, is_correct: is_correct})
      |> Ecto.Changeset.put_assoc(:scene, scene)
      |> Repo.insert()

  # ...

Enter fullscreen mode Exit fullscreen mode


Live games are powered by Phoenix Channels and we relied on Phoenix Presence to keep track of live users.

Phoenix Channels

When a user joins a new game with the game code, they are connected to the GenServer that corresponds with the code.

defmodule Web.GameChannel do
  # ...

  def join("game:" <> game_code, payload, socket) do
    case GameServer.game_pid(game_code) do
      pid when is_pid(pid) ->
        send(self(), {:after_join, game_code, payload["name"], payload["isSpectator"]})
        {:ok, assign(socket, :name, payload["name"])}

      nil ->
        {:error, %{reason: "Game does not exist"}}

  # ...
Enter fullscreen mode Exit fullscreen mode

Phoenix Presence

Using presence feels like cheating! We track the users and their scores in presence.

defmodule Web.GameChannel do
  # ...

  # Presence is tracked and initial game state broadcasted
  def handle_info({:after_join, game_code, name, is_spectator}, socket) do
    push(socket, "presence_state", Presence.list(socket))

    {:ok, _} =
      Presence.track(socket,, %{
        name: name,
        isSpectator: is_spectator,
        prevScore: 0,
        score: 0

    case is_spectator == true do
      true ->

      false ->
        GameServer.player_new(game_code, name)
    |> game_state_broadcast(socket)

    {:noreply, socket}

  # ...
Enter fullscreen mode Exit fullscreen mode


Each live game is its own GenServer process. Inside of the process we use ets to keep track of the game state.

defmodule Game.GameServer do
  use GenServer

  # ...

  @doc """
  Find or create game from ets
  def init({game_code, questions}) do
    game =
      case :ets.lookup(:games_table, game_code) do
        [] ->
          game =
          :ets.insert(:games_table, {game_code, game})

        [{^game_code, game}] ->
      end"Spawned game server process named '#{game_code}'.")

    {:ok, game, @timeout}

  # ...
Enter fullscreen mode Exit fullscreen mode

We are considering moving off of ets and use amnesia instead so that we won't lose game state on re-deploys.

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.