defmodule GameServer.Hooks do @moduledoc """ Behaviour for GameServer hooks/callbacks. Implement this behaviour in your hooks module to receive lifecycle events from the GameServer and run custom game logic. ## Setup 1. Create a module implementing this behaviour 2. Configure it in your GameServer instance ## Example defmodule MyGame.Hooks do @behaviour GameServer.Hooks @impl true def after_user_register(user) do # Give new users starting coins GameServer.Accounts.update_user(user, %{ metadata: Map.put(user.metadata || %{}, "coins", 100) }) end @impl true def after_user_login(user) do # Log login :ok end @impl true def after_user_updated(user) do # React to user profile changes (e.g., sync display name to external system) :ok end @impl true def before_user_update(_user, attrs) do # Validate or modify user update attributes before they are applied # Return {:ok, attrs} to allow, {:error, reason} to block {:ok, attrs} end # Lobby hooks @impl true def before_lobby_create(attrs) do # Validate or modify lobby creation attributes {:ok, attrs} end @impl true def after_lobby_create(_lobby), do: :ok @impl true def before_lobby_join(user, lobby, opts) do # Check if user can join (e.g., level requirements) {:ok, {user, lobby, opts}} end @impl true def before_group_create(user, attrs) do # Check if user can create a group (e.g., enough coins in metadata) coins = get_in(user.metadata, ["coins"]) || 0 if coins >= 50 do {:ok, attrs} else {:error, :not_enough_coins} end end @impl true def before_group_join(user, group, opts) do # Check if user can join group (e.g., level requirements based on metadata) {:ok, {user, group, opts}} end @impl true def after_lobby_join(_user, _lobby), do: :ok @impl true def before_lobby_leave(user, lobby) do {:ok, {user, lobby}} end @impl true def after_lobby_leave(_user, _lobby), do: :ok @impl true def before_lobby_update(_lobby, attrs) do {:ok, attrs} end @impl true def after_lobby_update(_lobby), do: :ok @impl true def before_lobby_delete(lobby) do {:ok, lobby} end @impl true def after_lobby_delete(_lobby), do: :ok @impl true def before_user_kicked(host, target, lobby) do {:ok, {host, target, lobby}} end @impl true def after_user_kicked(_host, _target, _lobby), do: :ok @impl true def after_lobby_host_change(_lobby, _new_host_id), do: :ok # Custom RPC handlers - define your own functions! # These are called from game clients via the RPC channel. # # def give_coins(amount, opts) do # caller = Keyword.get(opts, :caller) # # Update user's coins... # {:ok, %{new_balance: 150}} # end end ## Hook Types ### User Lifecycle Hooks - `after_user_register/1` - Called after a new user registers - `after_user_login/1` - Called after a user logs in - `after_user_updated/1` - Called after a user is updated (fire-and-forget) - `after_user_online/1` - Called after a user comes online (fire-and-forget) - `after_user_offline/1` - Called after a user goes offline (fire-and-forget) ### Lobby Lifecycle Hooks Before hooks can block operations by returning `{:error, reason}`. After hooks are fire-and-forget. - `before_lobby_create/1` - Before lobby creation, receives attrs map - `after_lobby_create/1` - After lobby is created - `before_lobby_join/3` - Before user joins lobby - `after_lobby_join/2` - After user joins lobby - `before_group_create/2` - Before group creation, receives `(user, attrs)`. Return `{:ok, attrs}` to allow or `{:error, reason}` to block - `after_group_create/1` - After group is created (fire-and-forget) - `before_group_join/3` - Before user is accepted into a group (public join, invite accept, or request approval) - `before_group_update/2` - Before group update, receives `(group, attrs)`. Return `{:ok, attrs}` to allow or `{:error, reason}` to block - `after_group_update/1` - After group is updated (fire-and-forget) - `after_group_join/2` - After a user joins a group (fire-and-forget), receives `(user_id, group)` - `after_group_leave/2` - After a user leaves a group (fire-and-forget), receives `(user_id, group_id)` - `after_group_delete/1` - After a group is deleted (fire-and-forget), receives `(group)` - `after_group_kick/3` - After a member is kicked from a group (fire-and-forget), receives `(admin_id, target_id, group_id)` - `before_party_create/2` - Before party creation, receives `(user, attrs)`. Return `{:ok, attrs}` to allow or `{:error, reason}` to block - `after_party_create/1` - After party is created (fire-and-forget) - `before_party_update/2` - Before party update, receives `(party, attrs)`. Return `{:ok, attrs}` to allow or `{:error, reason}` to block - `after_party_update/1` - After party is updated (fire-and-forget) - `after_party_join/2` - After a user joins a party via invite accept (fire-and-forget), receives `(user, party)` - `after_party_leave/2` - After a user leaves a party (fire-and-forget), receives `(user, party_id)` - `after_party_kick/3` - After a member is kicked from a party (fire-and-forget), receives `(target, leader, party)` - `after_party_disband/1` - After a party is disbanded (fire-and-forget), receives `(party)` - `before_chat_message/2` - Before a chat message is sent, receives `(user, attrs)`. Return `{:ok, attrs}` to allow (and optionally modify), or `{:error, reason}` to block - `after_chat_message/1` - After a chat message is persisted (fire-and-forget) - `before_lobby_leave/2` - Before user leaves lobby - `after_lobby_leave/2` - After user leaves lobby - `before_lobby_update/2` - Before lobby is updated - `after_lobby_update/1` - After lobby is updated - `before_lobby_delete/1` - Before lobby is deleted - `after_lobby_delete/1` - After lobby is deleted - `before_user_kicked/3` - Before user is kicked from lobby - `after_user_kicked/3` - After user is kicked from lobby - `after_lobby_host_change/2` - After lobby host changes - `before_kv_get/2` - Called before a KV `get` to determine whether a key should be publicly readable (`:public`) or restricted (`:private`) ## Custom RPC Functions Game clients can call RPCs exposed by your hooks module in two ways: 1. **Exported functions**: any exported function in your hooks module (other than the callbacks above) can be called from game clients. 2. **Dynamic functions (custom hooks)**: your `after_startup/0` callback may return a list of dynamic exports describing additional callable function names that do **not** need to exist as exported Elixir functions. These dynamic calls are dispatched to `on_custom_hook/2`. # Client calls: rpc("give_coins", {amount: 50}) def give_coins(amount, opts) do caller = Keyword.get(opts, :caller) # Your game logic here {:ok, %{success: true}} end Return values: - `{:ok, data}` - Success, data is sent back to client - `{:error, reason}` - Error, reason is sent back to client - `:ok` - Success with no data """ @typedoc "A user struct from GameServer.Accounts.User" @type user :: GameServer.Accounts.User.t() @typedoc "A lobby struct from GameServer.Lobbies.Lobby" @type lobby :: GameServer.Lobbies.Lobby.t() @typedoc "Result type for before hooks" @type hook_result(t) :: {:ok, t} | {:error, term()} @typedoc """ A dynamic RPC export returned by `after_startup/0`. The minimal shape is: %{hook: "custom_hello"} Optionally, provide `:meta` for tooling / UI hints: %{hook: "custom_hello", meta: %{description: "...", args: [...], example_args: [...]}} Note: the runtime also accepts string keys (e.g. `%{"hook" => ...}`), but this type focuses on the atom-keyed form. """ @type rpc_export :: %{ required(:hook) => String.t(), optional(:meta) => map() } @typedoc """ Options passed to hooks that accept an options map/keyword list. Common keys include `:user_id` (pos_integer) and other domain-specific options. Hooks may accept either a map or keyword list for convenience. """ @type kv_opts :: map() | keyword() # Startup/shutdown callbacks @callback after_startup() :: :ok | [rpc_export()] @callback before_stop() :: any() @doc """ Handle a dynamically-exported RPC function. This callback is invoked when a client calls a function name that was registered at runtime from `after_startup/0`. Receives the function name and the argument list. """ @callback on_custom_hook(String.t(), list()) :: any() # User lifecycle callbacks @callback before_user_update(user(), attrs :: map()) :: hook_result(map()) @callback after_user_register(user()) :: any() @callback after_user_login(user()) :: any() @callback after_user_updated(user()) :: any() @callback after_user_online(user()) :: any() @callback after_user_offline(user()) :: any() # Lobby lifecycle callbacks @callback before_lobby_create(attrs :: map()) :: hook_result(map()) @callback after_lobby_create(lobby()) :: any() @callback before_lobby_join(user(), lobby(), opts :: keyword()) :: hook_result({user(), lobby(), keyword()}) @callback after_lobby_join(user(), lobby()) :: any() @callback before_group_create(user(), map()) :: hook_result(map()) @callback after_group_create(term()) :: any() @callback before_group_join(user(), group :: map(), opts :: map()) :: hook_result({user(), map(), map()}) @callback before_group_update(term(), map()) :: hook_result(map()) @callback after_group_update(term()) :: any() # Group after-hooks (fire-and-forget) @callback after_group_join(integer(), term()) :: any() @callback after_group_leave(integer(), integer()) :: any() @callback after_group_delete(term()) :: any() @callback after_group_kick(integer(), integer(), integer()) :: any() # Party lifecycle callbacks @callback before_party_create(user(), map()) :: hook_result(map()) @callback after_party_create(term()) :: any() @callback before_party_update(term(), map()) :: hook_result(map()) @callback after_party_update(term()) :: any() @callback after_party_join(user(), term()) :: any() @callback after_party_leave(user(), integer()) :: any() @callback after_party_kick(user(), user(), term()) :: any() @callback after_party_disband(term()) :: any() @callback before_chat_message(user(), attrs :: map()) :: hook_result(map()) @callback after_chat_message(term()) :: any() @callback before_lobby_leave(user(), lobby()) :: hook_result({user(), lobby()}) @callback after_lobby_leave(user(), lobby()) :: any() @callback before_lobby_update(lobby(), attrs :: map()) :: hook_result(map()) @callback after_lobby_update(lobby()) :: any() @callback before_lobby_delete(lobby()) :: hook_result(lobby()) @callback after_lobby_delete(lobby()) :: any() @callback before_user_kicked(host :: user(), target :: user(), lobby()) :: hook_result({user(), user(), lobby()}) @callback after_user_kicked(host :: user(), target :: user(), lobby()) :: any() @optional_callbacks before_group_create: 2, after_group_create: 1, before_group_join: 3, before_group_update: 2, after_group_update: 1, before_user_update: 2, after_group_join: 2, after_group_leave: 2, after_group_delete: 1, after_group_kick: 3, before_party_create: 2, after_party_create: 1, before_party_update: 2, after_party_update: 1, after_party_join: 2, after_party_leave: 2, after_party_kick: 3, after_party_disband: 1, before_chat_message: 2, after_chat_message: 1 @doc """ Called before a KV `get/2` is performed. Implementations should return `:public` if the key may be read publicly, or `:private` to restrict access. Receives the `key` and an `opts` map/keyword (see `t:kv_opts/0`). Return either the bare atom (e.g. `:public`) or `{:ok, :public}`; return `{:error, reason}` to block the read. """ @callback before_kv_get(String.t(), kv_opts()) :: hook_result(:public | :private) @callback after_lobby_host_change(lobby(), new_host_id :: integer()) :: any() @doc """ Use this macro to get default implementations for all callbacks. This allows you to only implement the callbacks you need. ## Example defmodule MyGame.Hooks do use GameServer.Hooks @impl true def after_user_register(user) do # Only implement what you need :ok end end """ defmacro __using__(_opts) do quote do @behaviour GameServer.Hooks @impl true def after_startup, do: :ok @impl true def before_stop, do: :ok @impl true def on_custom_hook(_hook, _args), do: {:error, :not_implemented} @impl true def after_user_register(_user), do: :ok @impl true def after_user_login(_user), do: :ok @impl true def after_user_updated(_user), do: :ok @impl true def after_user_online(_user), do: :ok @impl true def after_user_offline(_user), do: :ok @impl true def before_user_update(_user, attrs), do: {:ok, attrs} @impl true def before_lobby_create(attrs), do: {:ok, attrs} @impl true def after_lobby_create(_lobby), do: :ok @impl true def before_lobby_join(user, lobby, opts), do: {:ok, {user, lobby, opts}} @impl true def before_group_create(_user, attrs), do: {:ok, attrs} @impl true def after_group_create(_group), do: :ok @impl true def before_group_update(_group, attrs), do: {:ok, attrs} @impl true def after_group_update(_group), do: :ok @impl true def after_group_join(_user_id, _group), do: :ok @impl true def after_group_leave(_user_id, _group_id), do: :ok @impl true def after_group_delete(_group), do: :ok @impl true def after_group_kick(_admin_id, _target_id, _group_id), do: :ok @impl true def before_party_create(_user, attrs), do: {:ok, attrs} @impl true def after_party_create(_party), do: :ok @impl true def before_party_update(_party, attrs), do: {:ok, attrs} @impl true def after_party_update(_party), do: :ok @impl true def after_party_join(_user, _party), do: :ok @impl true def after_party_leave(_user, _party_id), do: :ok @impl true def after_party_kick(_target, _leader, _party), do: :ok @impl true def after_party_disband(_party), do: :ok @impl true def after_lobby_join(_user, _lobby), do: :ok @impl true def before_chat_message(_user, attrs), do: {:ok, attrs} @impl true def after_chat_message(_message), do: :ok @impl true def before_lobby_leave(user, lobby), do: {:ok, {user, lobby}} @impl true def after_lobby_leave(_user, _lobby), do: :ok @impl true def before_lobby_update(_lobby, attrs), do: {:ok, attrs} @impl true def after_lobby_update(_lobby), do: :ok @impl true def before_lobby_delete(lobby), do: {:ok, lobby} @impl true def after_lobby_delete(_lobby), do: :ok @impl true def before_user_kicked(host, target, lobby), do: {:ok, {host, target, lobby}} @impl true def after_user_kicked(_host, _target, _lobby), do: :ok @impl true def after_lobby_host_change(_lobby, _new_host_id), do: :ok @impl true def before_kv_get(_key, _opts), do: :public defoverridable after_user_register: 1, after_user_login: 1, after_user_updated: 1, after_user_online: 1, after_user_offline: 1, before_user_update: 2, on_custom_hook: 2, before_lobby_create: 1, after_lobby_create: 1, before_group_create: 2, after_group_create: 1, before_group_update: 2, after_group_update: 1, after_group_join: 2, after_group_leave: 2, after_group_delete: 1, after_group_kick: 3, before_party_create: 2, after_party_create: 1, before_party_update: 2, after_party_update: 1, after_party_join: 2, after_party_leave: 2, after_party_kick: 3, after_party_disband: 1, before_lobby_join: 3, after_lobby_join: 2, before_chat_message: 2, after_chat_message: 1, before_lobby_leave: 2, after_lobby_leave: 2, before_lobby_update: 2, after_lobby_update: 1, before_lobby_delete: 1, after_lobby_delete: 1, before_user_kicked: 3, after_user_kicked: 3, after_lobby_host_change: 2, before_kv_get: 2 end end @doc """ Returns the raw caller value for the current hook invocation. When GameServer executes a hook function, it may inject a `:caller` into the hook task's process dictionary. This helper fetches that raw value. """ @spec caller() :: any() | nil def caller do case Application.get_env(:game_server_sdk, :stub_mode, :raise) do :placeholder -> %GameServer.Accounts.User{ id: 0, email: "", display_name: nil, metadata: %{}, is_admin: false, inserted_at: ~U[1970-01-01 00:00:00Z], updated_at: ~U[1970-01-01 00:00:00Z] } _ -> raise "#{__MODULE__}.caller/0 is a stub - only available at runtime on GameServer" end end @doc """ Returns the caller's numeric id when available. If the caller is a `GameServer.Accounts.User` struct, returns its `id`. If the caller is a map, returns `:id` or `"id"`. If the caller is already an integer, returns it. Otherwise returns `nil`. """ @spec caller_id() :: integer() | nil def caller_id do case Application.get_env(:game_server_sdk, :stub_mode, :raise) do :placeholder -> 0 _ -> raise "#{__MODULE__}.caller_id/0 is a stub - only available at runtime on GameServer" end end @doc """ Returns the user struct for the current caller when available. This is a convenience wrapper over `caller/0` that returns a user struct when the caller is already a `%GameServer.Accounts.User{}`. In the full GameServer application this may also resolve ids/maps via the DB. In the SDK it only returns the struct when it is already present. """ @spec caller_user() :: GameServer.Accounts.User.t() | nil def caller_user do case Application.get_env(:game_server_sdk, :stub_mode, :raise) do :placeholder -> %GameServer.Accounts.User{ id: 0, email: "", display_name: nil, metadata: %{}, is_admin: false, inserted_at: ~U[1970-01-01 00:00:00Z], updated_at: ~U[1970-01-01 00:00:00Z] } _ -> raise "#{__MODULE__}.caller_user/0 is a stub - only available at runtime on GameServer" end end end