Appearance
Guild Discovery
How Guild Search and Guild Recommendation work in the Metaplay guild framework.
Appearance
How Guild Search and Guild Recommendation work in the Metaplay guild framework.
Guild Discovery refers to Guild Recommendation and Guild Search operations that allow players to find guilds to join.
Guild discovery does not function on GuildModels and PlayerModels directly but instead on computed GuildDiscoveryInfoBase and GuildDiscoveryPlayerContext fragments. These fragments represent the necessary subset of the real data to perform discovery operations.
GuildDiscoveryInfoBase is generated by overriding GuildActor.CreateGuildDiscoveryInfo(). The data contains two kinds of classes of data: A public GuildDiscoveryInfo which is shared with client and a private ServerOnlyDiscoveryInfo, containing possible server-only sensitive information. Both are customizable by the game and may contain arbitrary serializable data.
Since the generated fragments may be stored in caches, it is up to the actor to refresh the discovery data after the data affecting it has changed. The discovery data can be refreshed by calling GuildActor.EnqueueGuildDiscoveryInfoUpdate() or by invoking ServerListenerCore.GuildDiscoveryInfoChanged().
For example: To keep track of whether a guild is Cool or not without letting clients know.
[MetaSerializableDerived(1)]
public sealed class GuildDiscoveryServerOnlyInfo : GuildDiscoveryServerOnlyInfoBase
{
[MetaMember(101)] public bool IsThisGuildCool;
}class MyGuildActor : GuildActorBase
{
protected override (GuildDiscoveryInfoBase, GuildDiscoveryServerOnlyInfoBase)
CreateGuildDiscoveryInfo()
{
bool isCool = Model.Members.Values.Average(m => m.PlayerCoolnessIndex) > 100;
return
(
new GuildDiscoveryInfo( /* ... */ ),
new GuildDiscoveryServerOnlyInfo(
/* ... */
isThisGuildCool: 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. GuildDiscoveryPlayerContext is generated by overriding PlayerActorBase.GuildComponentBase.CreateGuildDiscoveryContext().
Player context allows customizing the discovery results based on the calling player. 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 the caller is cool or not.
[MetaSerializableDerived(1)]
public class GuildDiscoveryPlayerContext : GuildDiscoveryPlayerContextBase
{
public bool IsCool;
}class MyPlayerActor : PlayerActorBase
{
public sealed class GuildComponent : GuildComponentBase<MyPlayerActor>
{
protected override GuildDiscoveryPlayerContextBase
CreateGuildDiscoveryContext()
{
return new GuildDiscoveryPlayerContext()
{
IsCool = Player.Model.CoolnessIndex >= 100,
};
}
}
}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 the query's GuildDiscoveryPlayerContext.
Game-specific filtering is implemented by overriding GuildSearchActorBase.FilterSearchResult() in your GuildSearchActor. For example: Don't allow totally uncool players to see Cool guilds and don't expose uncool guilds to cool players unless they specify TolerateUncoolGuilds.
[MetaSerializableDerived(1)]
public sealed class GuildSearchParams : GuildSearchParamsBase
{
[MetaMember(101)] bool TolerateUncoolGuilds;
}[EntityConfig]
public class GuildSearchConfig : GuildSearchConfigBase
{
public override Type EntityActorType => typeof(GuildSearchActor);
}
public class GuildSearchActor : GuildSearchActorBase
{
protected override bool FilterSearchResult(
GuildDiscoveryInfoBase publicDiscoveryInfoBase,
GuildDiscoveryServerOnlyInfoBase serverOnlyDiscoveryInfoBase,
GuildSearchParamsBase searchParamsBase,
GuildDiscoveryPlayerContextBase searchContextBase)
{
// convert from base types into actual types
GuildSearchParams searchParams = (GuildSearchParams)searchParamsBase;
GuildDiscoveryPlayerContext searchContext = (GuildDiscoveryPlayerContext)searchContextBase;
GuildDiscoveryServerOnlyInfo serverOnlyDiscoveryInfo = (GuildDiscoveryServerOnlyInfo)serverOnlyDiscoveryInfoBase;
// no cool guilds for uncools
if (!searchContext.IsCool && serverOnlyDiscoveryInfo.IsThisGuildCool)
return false;
// no uncool guilds for cools if not tolerated
if (searchContext.IsCool && !serverOnlyDiscoveryInfo.IsThisGuildCool && !searchParams.TolerateUncoolGuilds)
return false;
return true;
}
}The FilterSearchResult() filters guilds based on their discovery info, which may require refreshing and waking up the guild actor. Since this can take time, only a limited number of candidate guilds are fed into FilterSearchResult(). If the filtering query is narrow, this may result in search not finding any results.
The filtering precision can be improved by adding a database query level filter. For example, to add a prefilter for Player level, we first implement GetSearchFilter().
public class GuildSearchActor : GuildSearchActorBase
{
protected override GuildSearchFilterBuilder GetSearchFilter(
GuildSearchParamsBase searchParamsBase,
GuildDiscoveryPlayerContextBase searchContextBase)
{
GuildDiscoveryPlayerContext ctx = (GuildDiscoveryPlayerContext)searchContextBase;
GuildSearchFilterBuilder filter = new GuildSearchFilterBuilder();
filter.AddInterpolated($"RequiredPlayerLevel <= {ctx.PlayerLevel}");
return filter;
}
}The filter we added now expects Guilds database table to contain RequiredPlayerLevel column. Such column doesn't exist by default and we need to declare them:
[Index(nameof(EntityId), nameof(RequiredPlayerLevel))]
public class PersistedGuild : PersistedGuildBase
{
public int RequiredPlayerLevel { get; set; }
}Database Indices Required
For efficient filtering, the columns used in the filter should be added into a composite index, along with EntityId. EntityId should be the first column of the index.
Remember Migrations
Any mutation to the database definition requires a migration. Run dotnet ef migrations add UpdateGuildTable in the Backend/Server directory and commit the updated migration files.
After having defined the data model, fill the data in GuildActor:
class GuildActor : GuildActorBase<GuildModel, PersistedGuild>
{
protected override PersistedGuild CreatePersisted(EntityId entityId, DateTime persistedAt, byte[] payload, int schemaVersion, bool isFinal)
{
return new PersistedGuild()
{
// SDK columns
EntityId = entityId.ToString(),
PersistedAt = persistedAt,
Payload = payload,
SchemaVersion = schemaVersion,
IsFinal = isFinal,
// Custom columns
RequiredPlayerLevel = Model.RequiredPlayerLevel,
};
}
}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 the recommendation system as two separate operations: Maintenance of Guild Recommendation Pools and the generation of a recommendation by reading from these pools guided by GuildDiscoveryPlayerContext.
The Guild Recommendation pool is a persisted and potentially size-limited set of GuildDiscoveryGuildDatas 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 custom criteria, you need the following:
PersistedGuildDiscoveryPool.bool Filter(IGuildDiscoveryPool.GuildInfo info) method that decides whether the guild should be represented in this pool or not.bool ContextFilter(GuildDiscoveryPlayerContextBase playerContextBase, IGuildDiscoveryPool.GuildInfo info) 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.
The 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.
To recommend cool and uncool guilds to cool players and only uncool guilds to uncool players:
class CoolnessGuildPool : PersistedGuildDiscoveryPool
{
bool IsCoolPool;
public override sealed bool Filter(IGuildDiscoveryPool.GuildInfo info)
{
GuildDiscoveryServerOnlyInfo discoveryServerOnlyInfo =
(GuildDiscoveryServerOnlyInfo)info.ServerOnlyDiscoveryInfo;
// cool pool contains cool guilds, and uncool pool contains uncool guilds.
return discoveryServerOnlyInfo.IsThisGuildCool == IsCoolPool;
}
public override sealed bool ContextFilter(GuildDiscoveryPlayerContextBase playerContextBase, IGuildDiscoveryPool.GuildInfo info)
{
// we already do filtering when inserting, so nothing here
return true;
}
}
[EntityConfig]
public class GuildRecommenderConfig : GuildRecommenderConfigBase
{
public override Type EntityActorType => typeof(GuildRecommenderActor);
}
public class GuildRecommenderActor : GuildRecommenderActorBase
{
IGuildDiscoveryPool _coolGuilds;
IGuildDiscoveryPool _uncoolGuilds;
public GuildRecommenderActor()
{
_coolGuilds = RegisterPool(new CoolnessGuildPool("CoolGuilds", isCool: true));
_uncoolGuilds = RegisterPool(new CoolnessGuildPool("UncoolGuilds", isCool: false));
}
protected 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 to 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 to 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 above, we maintain a pool for cool guilds and a pool for uncool guild. The recommendation logic then picks candidates from eligible pools.
Note that these pooling rules, sizes, mixing logic, and ranges are for example only. You should customize these to fit the game needs. In the Idler Sample, we maintain four pools. 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.
💡 Tip
To create a feeling of urgency, keep a pool of full guilds and mix one or more such guilds into the results.
An 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 the joiner has an invitation:
class GuildActor
{
protected override bool ShouldAcceptPlayerJoin(ShouldAcceptPlayerJoinArgs args)
{
if (Model.JoinMode == InviteOnly && args is not ShouldAcceptPlayerJoinArgs.InvitationCodeJoinArgs)
return false;
...
}
}Hide invite-only guilds from recommendations:
Add GuildDiscoveryServerOnlyInfo { bool HideFromRecommendations }.
In GuildActor.CreateGuildDiscoveryInfo, set HideFromRecommendations for invite-only guilds.
Prevent hidden guilds entering the recommendation pools by adding a check in PersistedGuildDiscoveryPool.Filter:
class MyDiscoveryPool : PersistedGuildDiscoveryPool
{
public override sealed bool Filter(IGuildDiscoveryPool.GuildInfo info)
{
GuildDiscoveryServerOnlyInfo discoveryServerOnlyInfo =
(GuildDiscoveryServerOnlyInfo)info.ServerOnlyDiscoveryInfo;
if (discoveryServerOnlyInfo.HideFromRecommendations)
return false;
return true;
}
}The same can be done for Guild Search by adding (optionally) new HideFromSearch and filtering it out in GuildSearchFilter.FilterSearchResult().