Appearance
Appearance
🚧 Preview
The feature is still a work-in-progress. It is safe to use in production, but requires some game-specific work to implement.
GuildActor
operating on a GuildModel
.Guild state is represented as a GuildModel
which is similar to the PlayerModel
with similar annotations: Runtime data fields are marked with [IgnoreDataMember]
, fields private to the server are marked with [ServerOnly]
and normal public fields with just a [MetaMember()]
. However, not all are identical. Due to the model being server-driven (see Execution Model below), [NoChecksum]
does not need to be used for server-modifiable fields since all fields are modifiable.
🔄 Subject to change
More visibility classes may be added in the future. For example, a member might have fields visible to self and the server.
In the GuildModel
, all Guild Member Players are represented by a GuildMember
entry in the Members
dictionary. For convenience, GuildMember
may mirror some PlayerModel
data. For example, it is convenient that guild member's DisplayName
is always available for everybody that has access to the GuildModel
. Similar logic could apply player's country or any game-specific PlayerModel field. This mirrored data is represented in Metaplay guild framework as a GuildMemberPlayerData
(inheriting GuildMemberPlayerDataBase
) instance and this allows a convenient way to synchronize PlayerModel
fields into the respective GuildMember
Example. Mirror player's hypothetical Coolness:
class GuildMemberModel : GuildMemberBase
{
// this is publicly visible mirrored data. See below.
[MetaMember] int PlayerCoolnessIndex;
// this is publicly visible guild member data
[MetaMember] int NumTimesPoked;
// this is server-only visible, guild member data
[MetaMember, ServerOnly] RandomPCG Random;
}
class GuildModel
{
// this is publicly visible, guild data
[MetaMember] string Region;
// this is server-only visible, guild data
[MetaMember, ServerOnly] string Foobar;
...
}
class PlayerModel
{
[MetaMember] int CoolnessIndex;
...
}
// vessel to carry mirrored data
class GuildMemberPlayerData
{
[MetaMember] int CoolnessIndex;
override void ApplyOnMember(GuildMemberBase member)
{
GuildMember member = (GuildMember)memberBase;
member.CoolnessIndex = CoolnessIndex;
}
}
PlayerActor.CreateGuildMemberPlayerData()
{
return new GuildMemberPlayerData(coolnessIndex: Model.CoolnessIndex)
}
When a player's Personal Data is exported, all fields in GuildMember
annotated with [ExcludeFromGdprExport]
are automatically included in the report. [ExcludeFromGdprExport]
does nothing on GuildModel
root level. If GuildModel
contains Personal Data in any other field, this must be manually exported in GuildActor.GetMemberGdprExportExtraData()
.
The guild Execution model is similar to the Player Execution model: The model is updated by Ticks and Actions. However, since the guild is controlled by multiple clients, clients themselves do not update the model. Instead, clients propose Actions to be executed for the Guild, and the server then chooses to place them on the timeline and execute them, or refuse to execute on them. Ticks are never proposed and the server runs Ticks as necessary. The client sees changes to the Model only after the server has executed the action. Essentially executing an action has a one roundtrip delay and there is no speculation.
🔄 Subject to change
Lack of speculation is not by design, but just an implementation limitation. It is subject to change.
Actions in Guild must either be Server Actions or Client Actions. Server Actions are actions that only the server may invoke and it includes internal guild system actions. These actions inherit from GuildServerAction
. Client Actions on the other hand are actions that the client may propose to be executed. In both cases, the Action's InvokingPlayerId
is set to the PlayerId of the member that invoked the actions. For Server Actions the InvokingPlayerId
is by convention None
but this is merely a stylistic choice and not a technical requirement. If a Server Action is done on behalf of a member request, the invoking PlayerId could be set if so desired.
📝 Execution model details
Unlike PlayerServerActions, GuildServerActions do not require modified fields to be [NoChecksum]
.
Example:
class GuildPokeMember : GuildClientAction
{
public EntityId TargetPlayerId;
void Execute(GuildModel model, bool commit)
{
if (!player.Members.TryGetValue(InvokingPlayerId, out GuildMember member))
return MetaActionResult.NoSuchGuildMember;
if (TargetPlayerId == InvokingPlayerId)
return GuildActionResult.CannotPokeSelf;
if (commit)
member.NumTimesPoked++;
return MetaActionResult.Success;
}
}
A guild model has three lifecycle phases: Setup phase, Running phase, and Closed phase:
In Setup Phase, the guild is in the process of being created and it has no members yet. The guild is not running Ticks or Actions. This state should be very short-lived.
In the Running phase, the guild has members in it, and the guild may run Ticks and Actions.
When all players leave the guild, the guild becomes Closed. On this transition, the guild data is wiped to prevent storing Personal Information, and the guild no longer ticks or runs actions. The closed phase is terminal and such a guild cannot be joined.
GuildTransaction allows Guild and Player state to be inspected and mutated as if it was a single operation.
GuildTransaction is an operation in which Guild and Player state are inspected at the same time by the server, and then a server-side action is executed on both Guild and the Player. These server-issued actions are called Finalizing actions and they end the Transaction. Additionally, for improved feedback and to allow to manage state more conveniently, a transaction contains a client-side Initiating action. This action is a part of the transaction but does not have any execution order guarantees except that it happens before the finalizing actions.
Player actions are executed as follows:
+---------+ +---------+
| Client | | Server |
+---------+ +---------+
| |
| Transaction request |
|------------------------->|
/ Initiating Player \ -- | | -- / Initiating Player \
\ Action / | | \ Action /
| |
/ Normal actions \ -- | Flush |
\ and Ticks / |---> |
| |
| Transaction Reply |
|<-------------------------|
/ Finalizing Player \ -- | Flush |
\ Action / | ---> | -- / Normal actions \
| | \ and Ticks /
| Transcation Ack |
|------------------------->|
| | -- / Finalizing Player \
| | \ Action /
V V
Guild actions are executed as follows:
+---------+ +---------+
| Client | | Server |
+---------+ +---------+
| |
| Flush | -- / Normal actions \
| <---| \ and Ticks /
| |
| Transaction request |
|------------------------->|
| | -- / Finalizing Guild \
| Flush | \ Action /
/ Normal actions \ -- | <--- |
\ and Ticks / | |
| Transaction Reply |
|<-------------------------|
/ Finalizing Guild \ -- | |
\ Action / | |
| |
V V
For performance reasons, during the inspection of the Guild and the Player state, only a subset of the state is inspected. This subset is called a Plan. For PlayerModel
we create a PlayerPlan and for GuildModel
we create a GuildPlan. Additionally, we have an option to supply server-side secrets in the form of ServerPlan but this is currently unused. For convenience, these Plans are combined into a FinalizingPlan which is then used to create the final Actions.
Data flows as follows:
+-------------+ +------------+
| PlayerModel | | GuildModel |
+-------------+ +------------+
| |
V V
(Player Planning) (Guild Planning)
| |
V V
+------------+ +-----------+ +------------+
| PlayerPlan | | GuildPlan | | ServerPlan |
+------------+ +-----------+ +------------+
| | | | |
| | '------------. | .---------'
| | V V V
| | ( Final Planning )
| | |
| | V
| | +----------------+
| | | FinalizingPlan |
| | +----------------+
| | | |
| '---------. | '----------------.
V V V V
+--------------+ +--------------+ +--------------+ +-------------+
| Initiating | | Finalizing | | Finalizing | | Finalizing |
| PlayerAction | | (Cancelling) | | (Successful) | | GuildAction |
+--------------+ | PlayerAction | | PlayerAction | +-------------+
+--------------+ +--------------+
Transaction can Terminate in three different ways.
If transaction preconditions are not fulfilled, the transaction is Aborted. This is invoked by either throwing TransactionPlanningFailure
during the Player Planning step or by having execution of the Initiating PlayerAction complete non-successfully. For example, if the player does not have a sufficient amount of resources to buy a certain item. When the transaction is Aborted, no actions are executed.
If a transaction becomes stale for system or game-specific reasons, the transaction is Cancelled. For example, if the player is kicked from the guild, but the client has not observed this yet. Or the item is no longer available when the server attempts to process a purchase request. This can be invoked by throwing a TransactionPlanningFailure
during any of the remaining planning phases. When a transaction is Cancelled, no guild actions are executed and hence the guild model is not modified. The player model executes both Initiating PlayerAction and then the Cancelling PlayerAction. Cancelling action is executed as if it were a Finalizing PlayerAction.
If a transaction is successful, Guild executes Finalizing GuildAction, and the player executes both Initiating PlayerAction and Finalizing PlayerAction.
Sequence of operations is as follows:
|
V
[ Player planning ] ---> (failure) --> [ Abort ]
|
V
[ Initiating Action ] ---> (failure) --> [ Abort ]
|
(success)
|
V
[ Guild and Server Planning ] ---> (failure) --> [ Canceling Action ]
|
(success)
|
V
[ Finalizing Actions ]
For an example Transaction implementations, check out example transactions in Idler project.
Metaplay guild framework models guild permissions around Permissions and Roles.
Permissions are simple: Each built-in operation checks before applying changes that the invoking player has the necessary permission by calling GuildModel.HasPermissionTo*
hook. Permission checking is checked for the invoking player, but it is recommended (but not required) that permissions are checked against the invoking player's role.
Roles are a bit more complex: Each member in a guild is assigned to a single Role. Roles are abstract labels and Metaplay guild framework does not have built-in assumptions on them. For example, roles are not assumed to be increasing linear supersets of preceding roles and advanced structures such as lattices are supported. A game may define suitable guild roles declaring them in GuildMemberRole
enumeration in GuildMemberRole.cs
. See doc-comment for more details.
Role assignment is defined via game-specific GuildModel.ComputeRoleChangesForRoleEvent
hook. This allows the game to specify role invariants such that there is always a single Leader (or guild is empty). For reference, the default implementation maintains a single Leader. In the case of a Leader leaving or being demoted, another player with the highest role (assuming linearity) is then chosen as the next Leader. See doc-comment for more details.
Metaplay SDK contains some tools for guild management. Currently, the SDK contains following built-in tools:
GuildMemberKick
client action checks the permission for kicking the target player with HasPermissionToKickMember
hook. If the check passes, the target player is kicked (removed from the guild) with a given kick reason.
🔄 Subject to change
Kick reason is not yet delivered to client.
🔄 Subject to change
Banning (permanent or timed) player from joining is not yet supported.
GuildMemberEditRole
client action checks the permission for changing the role of a target player with HasPermissionToChangeRoleTo
hook. If the check passes, the roles are updated as ComputeRoleChangesForRoleEvent
computes them.
Guild Discovery refers to Guild Recommendation and Guild Search operations.
Guild discovery does not function on GuildModel
s directly but on computed GuildDiscoveryGuildData
fragments. A single GuildDiscoveryGuildData
contains two kinds of arbitrary data: A public GuildDiscoveryInfo
and a private ServerOnlyDiscoveryInfo
. Both are customizable by the game and may contain arbitrary serializable data. These are generated in GuildActor.CreateGuildDiscoveryInfo
. Since the generated fragments may be stored in caches and data update is modeled (seemingly) as a push-model, it is up to the actor to refresh the discovery data after the data affecting is has changed. This can be done by calling GuildActor.EnqueueGuildDiscoveryInfoUpdate()
or by invoking GuildServerListenre
.
🔄 Subject to change
GuildDiscoveryInfoChanged
will be changed to GuildDiscoveryDataChanged
in the future.
Example. Keep a secretly track whether a guild is Cool or not.
public sealed class GuildDiscoveryServerOnlyInfo : GuildDiscoveryServerOnlyInfoBase
{
[MetaMember(101)] public bool IsThisGuildCool;
}
GuildDiscoveryGuildData GuildActor.CreateGuildDiscoveryInfo()
{
bool isCool = (if average member.PlayerCoolnessIndex > 100);
return new GuildDiscoveryGuildData(
...
ServerOnlyDiscoveryInfo = new GuildDiscoveryServerOnlyInfo(isCool)
)
}
The Guild discovery operation happens always within the context of an invoking player. However, the search and recommender do not use the whole PlayerModel
or access the PlayerActor
but instead use a minimal Player Context. This is represented as a GuildDiscoveryPlayerContext
which may contain any game-specific data. For example, to recommend certain guilds for players above level 5, we would store the player level in Context (or the fact that we are above the limit) and then in Recommender or Search inspect this value.
Example. Determine if caller is cool or not.
struct GuildDiscoveryPlayerContext
{
bool IsCool;
}
PlayerActor.CreateGuildDiscoveryContext()
{
isCool = Model.CoolnessIndex >= 100;
return new GuildDiscoveryPlayerContext(isCool)
}
Guild Search searches for existing guilds with a given name fragment and custom game-specific filters as defined in GuildSearchParams
. The search is implemented in two steps: first built-in filtering followed by custom filtering. In built-in filtering, GuildModel.DisplayName
(or more specifically GuildDiscoveryGuildData.PublicDiscoveryInfo.DisplayName
) is checked to contain a given case-insensitive substring. Then game-specific custom filtering selects matching guilds based on GuildDiscoveryGuildData
and query's GuildDiscoveryPlayerContext
.
Game-specific filtering is implemented in GuildSearchActor.FilterSearchResult
in GuildSearchActor.cs
Example. Don't allow totally uncool players see Cool guilds and don't expose uncool guilds to cool players unless they specify TolerateUncoolGuilds
.
class GuildSearchParams
{
bool TolerateUncoolGuilds;
}
class GuildSearchActor
{
...
public override bool FilterSearchResult(GuildDiscoveryGuildData guildData, GuildSearchParamsBase searchParamsBase, GuildDiscoveryPlayerContextBase searchContextBase)
{
// convert from base types into actual types
GuildSearchParams searchParams = (GuildSearchParams)searchParams;
GuildDiscoveryPlayerContext searchContext = (GuildDiscoveryPlayerContextBase)searchContextBase;
// no cool guilds for uncools
if (!searchContext.IsCool && guildData.ServerOnlyDiscoveryInfo.IsThisGuildCool)
return false;
// no uncool guilds for cools if not tolerated
if (searchContext.IsCool && !guildData.ServerOnlyDiscoveryInfo.IsThisGuildCool && !searchParams.TolerateUncoolGuilds)
return false;
return true;
}
}
The guild recommendation framework in Metaplay does not contain a complete guild recommendation system per-se. Instead, Metaplay contains the necessary tools to create a recommendations system suitable for your game. Metaplay models recommendation system as two separate operations: Maintenance of Guild Recommendation Pools and the generations of a recommendation by reading from these pools guided by GuildDiscoveryPlayerContext
.
Guild Recommendation pool is a persisted and potentially size-limited set of GuildDiscoveryGuildData
s that fulfill certain game-specific criteria. Pools are independent of each other and a guild may be represented in multiple (or none) pools at a time. The pools are automatically updated when the guild's GuildDiscoveryGuildData
is updated.
To implement a recommendation pool with a custom criteria, you need following:
PersistedGuildDiscoveryPool
bool Filter(in GuildDiscoveryGuildData data)
method that decides whether the guild should be represented in this pool or not.bool ContextFilter(GuildDiscoveryPlayerContextBase playerContext, in GuildDiscoveryGuildData data)
which decides whether the certain guild in this pool is allowed to be recommended to the player.GuildRecommenderActor
🔄 Subject to change
ContextFilter
may be changed in the future to move query logic from pools nearer to the rest of the query logic.
🔄 Subject to change
Built-in support for private/hidden/invite-only guilds that are never recommended (but are findable with search) is missing.
Recommendation operation then fetches from the desired pools and mixes the results together. Edge cases, such as multiple pools returning the same guild are handled automatically and the resulting set will only contain the duplicated guild once.
🔄 Subject to change
All guilds in a pool (that match the filters) are currently considered equally good candidates and no sorting is performed.
Example. Recommend cool and uncool guilds to cool players and only uncool guilds to uncool players. GuildRecommender.cs:
class CoolnessGuildPool : PersistedGuildDiscoveryPool
{
bool IsCoolPool;
bool Filter(in GuildDiscoveryGuildData data)
{
// cool pool contains cool guilds, and uncool pool uncool guilds.
return data.IsThisGuildCool == IsCoolPool;
}
bool ContextFilter(in GuildDiscoveryPlayerContext playerContext, in GuildDiscoveryGuildData data)
{
// we already do filtering when querying, so nothing here
return true;
}
}
public class GuildRecommenderActor : GuildRecommenderActorBase
{
IGuildDiscoveryPool _coolGuilds;
IGuildDiscoveryPool _uncoolGuilds;
public GuildRecommenderActor(EntityId entityId) : base(entityId)
{
_coolGuilds = RegisterPool(new CoolnessGuildPool("CoolGuilds", isCool: true));
_uncoolGuilds = RegisterPool(new CoolnessGuildPool("UncoolGuilds", isCool: false))
}
override List<GuildDiscoveryInfoBase> CreateRecommendations(GuildDiscoveryPlayerContextBase playerContextBase)
{
GuildDiscoveryPlayerContext playerContext = (GuildDiscoveryPlayerContext)playerContextBase;
// Get 20 guilds. Ideally 10 uncool and 10 cool if possible.
GuildRecommendationMixer mixer = new GuildRecommendationMixer();
// Always mix from uncools. Try get at least 10. Can take up to 20 if
// other sources lack data.
mixer.AddSource(_uncoolGuilds, playerContext, minCount: 10, maxCount: 20);
// Cool players get additionally from cool guilds. Try get at least 10.
// Can take up to 20 if other sources lack data.
if (playerContext.IsCool)
mixer.AddSource(_coolGuilds, playerContext, minCount: 10, maxCount: 20);
// take 20
return mixer.Mix(maxCount: 20);
}
}
In the example integration, four pools are maintained. Three for different hard-coded size ranges, and one for recently created guilds. The recommendation logic then takes a few from each size-pool and one from the latest guilds pool. Note that these pooling rules, sizes, mixing logic, and ranges are for example only. You should customize these to fit the game needs.
💡 Tip
To create a feeling of urgency, keep a pool of full guilds and mix one or more such guilds into the results.
Invite-only guild is a hidden guild that can only be joined by using a Guild Invite. It can be implemented as follows:
Allow joining only if joiner has an invitation:
bool GuildActor.ShouldAcceptPlayerJoin(
EntityId playerId,
GuildMemberPlayerDataBase playerData,
bool isInvited)
{
if (Model.JoinMode == InviteOnly && !isInvited)
return false;
...
}
Hide invite-only guilds from recommendation:
Add GuildDiscoveryServerOnlyInfo { bool HideFromRecommendations }
In GuildAcotr.CreateGuildDiscoveryInfo
set HideFromRecommendations
for invite-only guilds
Filter hidden guilds from recommendation pools, for example by adding a check in CommonDiscoveryPoolContextFilters
:
CommonDiscoveryPoolContextFilters.Test(
in GuildDiscoveryPlayerContext playerContext,
in GuildDiscoveryGuildData data)
{
if (data.HideFromRecommendations)
return false;
...
}
Same can be done for Guild Search by adding (optionally) new HideFromSearch
and filtering it out in GuildSearchFilter.FilterSearchResult
.
Guild State management for the game client is handled by MetaplayGuildClient
. It additionally handles the state management for ongoing Discovery operations. The various aspects of guild state management are exposed as follows:
MetaplayGuildClient
exposes the player's current guild's state as a following state machine:
+-----------+ / Session \
| NoSession | <-------- \ lost /
+-----------+
| | |
.-------------' V '-------------.
| +---------------+ |
| | EntityLoading | |
| +---------------+ |
| | | |
| .----------' '----------. |
| | | |
V V V V
+-----------+ +-------------+
| NoGuild | <----------------| GuildActive |
+-----------+ +-------------+
^ | ^ | ^ ^ ^
| | | | +---------------+ | | |
| | | '--> | CreatingGuild | | | +---------------+
| | | +---------------+ | | | JoiningGuild |
| | | | | | | +---------------+
| | '----------' '-------' | ^
| | | |
| | +---------------+ | / Server forces \
| '-------> | JoiningGuild | | \ player to join /
| +---------------+ |
| | | |
'---------------' '------------'
On each transition, PhaseChanged
event is invoked, with the exception being the Session Lost and Force Join interrupts. In these cases, the state machine is not transitioned but reset to the new state immediately and no transitions happen.
When Phase is GuildActive
, the guild client's GuildContext
contains the current Guild's GuildModel
. The game may track changes in the model by attaching ModelListener
or by attaching to ActiveGuildUpdated
event. Refer to the class doc-comments for more information.
🔄 Subject to change
Guild creation may change into being a PlayerAction to allow for advanced use cases such as making Guild creation to cost game currency, or depend on player state.
The result from a guild discovery operation is a set of GuildDiscoveryInfo
which is sufficient for identifying a guild and showing minimal information in the UI. However, the data in GuildDiscoveryInfo
is very limited and often insufficient for any more involved inspection. For example, imagine a user searches for a guild and wants to know whether a certain player is a member of it. Since storing all members' data in GuildDiscoveryInfo
is not feasible, Metaplay allows guild non-members to inspect "foreign" guilds with Guild Views.
Usage:
// Use GuildClient.BeginViewGuild to open a view. This takes some time and can fail
GuildClient.BeginViewGuild(guildId, (foreignContextOrNull) => ...)
// or await GuildClient.BeginViewGuild(guildId)
// inspect view
ForeignGuildContext context = ...
... = context.Model.Region
... = context.Model.Members[playerId].PlayerCoolnessIndex
// close view after no longer needed
context.Dispose()
🔄 Subject to change
Open guild views are not updated in the current implementation.
🔄 Subject to change
In the current implementation Guild View is able to inspect all GuildModel
states. A way to limit this data access and keep it private (only shared to server and guild members) may be added in the future.
🔄 Subject to change
The number of concurrent views is currently unlimited at the API level. The implementation may limit the count of views but it lacks the information to decide which views to close or prevent opening. This might change to a "slot" driven approach where a client has only a certain number of view slots it may concurrently use.
Name | Type | Description |
---|---|---|
game_guild_started_total | Counter | Number of guild actors started |
game_guild_persisted_size | Histogram | Persisted size of guild logic state |
game_guild_transactions_total[type=TransactionTypeName] | Counter | Total amount of Guild Transactions attempted |
game_guild_transaction_cancels_total[source=SourceActor] | Counter | Total amount of Guild Transaction attempts that ended up in cancel, by source |
game_guild_transaction_errors_total[source=SourceActor] | Counter | Total amount of Guild Transaction attempts that ended up in error, by error source |
game_guild_transaction_duration | Histogram | Guild Transaction duration, as measured from Session |
game_guild_recommender_requests_total | Counter | Total amount of Guild Recommendation requests made |
game_guild_recommender_request_errors_total | Counter | Total amount of errors during Guild Recommendation requests |
game_guild_recommender_duration | Histogram | Guild Recommendation duration |
game_guildsearch_requests_total | Counter | Number of guild search requests |
game_guildsearch_errors_total | Counter | Number of guild searches that ended with error |
game_guildsearch_search_duration | Histogram | Duration of successful guild searches, including queue time |